Import of the watch repository from Pebble

This commit is contained in:
Matthieu Jeanson 2024-12-12 16:43:03 -08:00 committed by Katharine Berry
commit 3b92768480
10334 changed files with 2564465 additions and 0 deletions

89
python_libs/pebble-commander/.gitignore vendored Normal file
View file

@ -0,0 +1,89 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# IPython Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject

View file

@ -0,0 +1,2 @@
Pebble Commander
================

View file

@ -0,0 +1,15 @@
# Copyright 2024 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.
__import__('pkg_resources').declare_namespace(__name__)

View file

@ -0,0 +1,16 @@
# Copyright 2024 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.
from .commander import PebbleCommander
from . import _commands

View file

@ -0,0 +1,67 @@
# Copyright 2024 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.
from __future__ import absolute_import
import argparse
import logging
import sys
from . import interactive
def main(args=None):
def reattach_handler(logger, formatter, handler):
if handler is not None:
logger.removeHandler(handler)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
logger.addHandler(handler)
return handler
if args is None:
parser = argparse.ArgumentParser(description='Pebble Commander.')
parser.add_argument('-v', '--verbose', help='verbose logging', action='count',
default=0)
parser.add_argument('-t', '--tty', help='serial port (defaults to auto-detect)', metavar='TTY',
default=None)
parser.add_argument('-c', '--pcap', metavar='FILE', default=None,
help='write packet capture to pcap file')
parser.add_argument('dict', help='log-hashing dictionary file', metavar='loghash_dict.json',
nargs='?', default=None)
args = parser.parse_args()
log_level = (logging.DEBUG if args.verbose >= 2
else logging.INFO if args.verbose >= 1
else logging.WARNING)
use_colors = True
formatter_string = '%(name)-12s: %(levelname)-8s %(message)s'
if use_colors:
formatter_string = '\x1b[33m%s\x1b[m' % formatter_string
formatter = logging.Formatter(formatter_string)
handler = reattach_handler(logging.getLogger(), formatter, None)
logging.getLogger().setLevel(log_level)
with interactive.InteractivePebbleCommander(
loghash_path=args.dict, tty=args.tty, capfile=args.pcap) as cmdr:
cmdr.attach_prompt_toolkit()
# Re-create the logging handler to use the patched stdout
handler = reattach_handler(logging.getLogger(), formatter, handler)
cmdr.command_loop()
if __name__ == '__main__':
main()

View file

@ -0,0 +1,27 @@
# Copyright 2024 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.
from . import app
from . import battery
from . import bluetooth
from . import clicks
from . import flash
from . import help
from . import imaging
from . import misc
from . import pfs
from . import resets
from . import system
from . import time
from . import windows

View file

@ -0,0 +1,85 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def app_list(cmdr):
""" List applications.
"""
return cmdr.send_prompt_command("app list")
@PebbleCommander.command()
def app_load_metadata(cmdr):
""" Ghetto metadata loading for pbw_image.py
"""
ret = cmdr.send_prompt_command("app load metadata")
if not ret[0].startswith("OK"):
raise exceptions.PromptResponseError(ret)
@PebbleCommander.command()
def app_launch(cmdr, idnum):
""" Launch an application.
"""
idnum = int(str(idnum), 0)
if idnum == 0:
raise exceptions.ParameterError('idnum out of range: %d' % idnum)
ret = cmdr.send_prompt_command("app launch %d" % idnum)
if not ret[0].startswith("OK"):
raise exceptions.PromptResponseError(ret)
@PebbleCommander.command()
def app_remove(cmdr, idnum):
""" Remove an application.
"""
idnum = int(str(idnum), 0)
if idnum == 0:
raise exceptions.ParameterError('idnum out of range: %d' % idnum)
ret = cmdr.send_prompt_command("app remove %d" % idnum)
if not ret[0].startswith("OK"):
raise exceptions.PromptResponseError(ret)
@PebbleCommander.command()
def app_resource_bank(cmdr, idnum=0):
""" Get resource bank info for an application.
"""
idnum = int(str(idnum), 0)
if idnum < 0:
raise exceptions.ParameterError('idnum out of range: %d' % idnum)
ret = cmdr.send_prompt_command("resource bank info %d" % idnum)
if not ret[0].startswith("OK "):
raise exceptions.PromptResponseError(ret)
return [ret[0][3:]]
@PebbleCommander.command()
def app_next_id(cmdr):
""" Get next free application ID.
"""
return cmdr.send_prompt_command("app next id")
@PebbleCommander.command()
def app_available(cmdr, idnum):
""" Check if an application is available.
"""
idnum = int(str(idnum), 0)
if idnum == 0:
raise exceptions.ParameterError('idnum out of range: %d' % idnum)
return cmdr.send_prompt_command("app available %d" % idnum)

View file

@ -0,0 +1,35 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def battery_force_charge(cmdr, charging=True):
""" Force the device to believe it is or isn't charging.
"""
if parsers.str2bool(charging):
charging = "enable"
else:
charging = "disable"
ret = cmdr.send_prompt_command("battery chargeopt %s" % charging)
if ret:
raise exceptions.PromptResponseError(ret)
@PebbleCommander.command()
def battery_status(cmdr):
""" Get current battery status.
"""
return cmdr.send_prompt_command("battery status")

View file

@ -0,0 +1,82 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def bt_airplane_mode(cmdr, enter=True):
""" Enter or exit airplane mode.
`enter` should either be a boolean, "enter", or "exit".
"""
if parsers.str2bool(enter, also_true=["enter"], also_false=["exit"]):
enter = "enter"
else:
enter = "exit"
ret = cmdr.send_prompt_command("bt airplane mode %s" % enter)
if ret:
raise exceptions.PromptResponseError(ret)
@PebbleCommander.command()
def bt_prefs_wipe(cmdr):
""" Wipe bluetooth preferences.
"""
ret = cmdr.send_prompt_command("bt prefs wipe")
if ret:
raise exceptions.PromptResponseError(ret)
@PebbleCommander.command()
def bt_mac(cmdr):
""" Get the bluetooth MAC address.
"""
ret = cmdr.send_prompt_command("bt mac")
if not ret[0].startswith("0x"):
raise exceptions.PromptResponseError(ret)
retstr = ret[0][2:]
return [':'.join(retstr[i:i+2] for i in range(0, len(retstr), 2))]
@PebbleCommander.command()
def bt_set_addr(cmdr, new_mac=None):
""" Set the bluetooth MAC address.
Don't specify `new_mac` to revert to default.
`new_mac` should be of the normal 6 hex octets split with colons.
"""
if not new_mac:
new_mac = "00:00:00:00:00:00"
mac = parsers.str2mac(new_mac)
macstr = ''.join(["%02X" % byte for byte in mac])
ret = cmdr.send_prompt_command("bt set addr %s" % macstr)
if ret[0] != new_mac:
raise exceptions.PromptResponseError(ret)
@PebbleCommander.command()
def bt_set_name(cmdr, new_name=None):
""" Set the bluetooth name.
"""
if not new_name:
new_name = ""
# Note: the only reason for this is because prompt sucks
# This can probably be removed when prompt goes away
if ' ' in new_name:
raise exceptions.ParameterError("bluetooth name must not have spaces")
ret = cmdr.send_prompt_command("bt set name %s" % new_name)
if ret:
raise exceptions.PromptResponseError(ret)

View file

@ -0,0 +1,58 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def click_short(cmdr, button):
""" Click a button.
"""
button = int(str(button), 0)
if not 0 <= button <= 3:
raise exceptions.ParameterError('button out of range: %d' % button)
ret = cmdr.send_prompt_command("click short %d" % button)
if not ret[0].startswith("OK"):
raise exceptions.PromptResponseError(ret)
@PebbleCommander.command()
def click_long(cmdr, button, hold_ms=20):
""" Hold a button.
`hold_ms` is how many ms to hold the button down before releasing.
"""
return cmdr.click_multiple(button, hold_ms=hold_ms)
@PebbleCommander.command()
def click_multiple(cmdr, button, count=1, hold_ms=20, delay_ms=0):
""" Rhythmically click a button.
"""
button = int(str(button), 0)
count = int(str(count), 0)
hold_ms = int(str(hold_ms), 0)
delay_ms = int(str(delay_ms), 0)
if not 0 <= button <= 3:
raise exceptions.ParameterError('button out of range: %d' % button)
if not count > 0:
raise exceptions.ParameterError('count out of range: %d' % count)
if hold_ms < 0:
raise exceptions.ParameterError('hold_ms out of range: %d' % hold_ms)
if delay_ms < 0:
raise exceptions.ParameterError('delay_ms out of range: %d' % delay_ms)
ret = cmdr.send_prompt_command(
"click multiple {button:d} {count:d} {hold_ms:d} {delay_ms:d}".format(**locals()))
if not ret[0].startswith("OK"):
raise exceptions.PromptResponseError(ret)

View file

@ -0,0 +1,61 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
# TODO: flash-write
# Can't do it with pulse prompt :(
@PebbleCommander.command()
def flash_erase(cmdr, address, length):
""" Erase flash area.
"""
address = int(str(address), 0)
length = int(str(length), 0)
if address < 0:
raise exceptions.ParameterError('address out of range: %d' % address)
if length <= 0:
raise exceptions.ParameterError('length out of range: %d' % length)
# TODO: I guess catch errors
ret = cmdr.send_prompt_command("erase flash 0x%X %d" % (address, length))
if not ret[1].startswith("OK"):
raise exceptions.PromptResponseError(ret)
@PebbleCommander.command()
def flash_crc(cmdr, address, length):
""" Calculate CRC of flash area.
"""
address = int(str(address), 0)
length = int(str(length), 0)
if address < 0:
raise exceptions.ParameterError('address out of range: %d' % address)
if length <= 0:
raise exceptions.ParameterError('length out of range: %d' % length)
# TODO: I guess catch errors
ret = cmdr.send_prompt_command("crc flash 0x%X %d" % (address, length))
if not ret[0].startswith("CRC: "):
raise exceptions.PromptResponseError(ret)
return [ret[0][5:]]
@PebbleCommander.command()
def prf_address(cmdr):
""" Get address of PRF.
"""
ret = cmdr.send_prompt_command("prf image address")
if not ret[0].startswith("OK "):
raise exceptions.PromptResponseError(ret)
return [ret[0][3:]]

View file

@ -0,0 +1,133 @@
# Copyright 2024 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.
import inspect
import sys
from .. import PebbleCommander, exceptions, parsers
def trim_docstring(var):
return inspect.getdoc(var) or ''
def get_help_short(cmdr, cmd_name, help_output=None):
"""
cmd_name is the command's name.
help_output is the raw output of the `!help` command.
"""
output = None
func = cmdr.get_command(cmd_name)
if func: # Host command
# cmdstr is the actual function name
cmdstr = func.name
spec = inspect.getargspec(func)
if len(spec.args) > 1:
maxargs = len(spec.args) - 1
if spec.defaults is None:
cmdstr += " {%d args}" % maxargs
else:
minargs = maxargs - len(spec.defaults)
cmdstr += " {%d~%d args}" % (minargs, maxargs)
if func.__doc__ is not None:
output = "%-30s - %s" % (cmdstr, trim_docstring(func).splitlines()[0])
else:
output = cmdstr
else: # Prompt command
if cmd_name[0] == '!': # Strip the bang if it's there
cmd_name = cmd_name[1:]
# Get the output if it wasn't provided
if help_output is None:
help_output = cmdr.send_prompt_command("help")
for prompt_cmd in help_output[1:]:
# Match, even with argument count provided
if prompt_cmd == cmd_name or prompt_cmd.startswith(cmd_name+" "):
# Output should be the full argument string with the bang
output = '!' + prompt_cmd
break
return output
def help_arginfo_nodefault(arg):
return "%s" % arg.upper()
def help_arginfo_default(arg, dflt):
return "[%s (default: %s)]" % (arg.upper(), str(dflt))
def get_help_long(cmdr, cmd_name):
output = ""
func = cmdr.get_command(cmd_name)
if func:
spec = inspect.getargspec(func)
specstr = []
for i, arg in enumerate(spec.args[1:]):
if spec.defaults is not None:
minargs = len(spec.args[1:]) - len(spec.defaults)
if i >= minargs:
specstr.append(help_arginfo_default(arg, spec.defaults[i - minargs]))
else:
specstr.append(help_arginfo_nodefault(arg))
else:
specstr.append(help_arginfo_nodefault(arg))
specstr = ' '.join(specstr)
cmdstr = func.name + " " + specstr
if func.__doc__ is None:
output = "%s\n\nNo help available." % cmdstr
else:
output = "%s - %s" % (cmdstr, trim_docstring(func))
else: # Prompt command
cmdstr = get_help_short(cmdr, cmd_name)
if cmdstr is None:
output = None
else:
output = "%s\n\nNo help available, due to being a prompt command." % cmdstr
return output
@PebbleCommander.command()
def help(cmdr, cmd=None):
""" Show help.
You're lookin' at it, dummy!
"""
out = []
if cmd is not None:
helpstr = get_help_long(cmdr, cmd)
if helpstr is None:
raise exceptions.ParameterError("No command '%s' found." % cmd)
out.append(helpstr)
else: # List commands
out.append("===Host commands===")
# Bonus, this list is sorted for us already
for cmd_name in dir(cmdr):
if cmdr.get_command(cmd_name):
out.append(get_help_short(cmdr, cmd_name))
out.append("\n===Prompt commands===")
ret = cmdr.send_prompt_command("help")
if ret[0] != 'Available Commands:':
raise exceptions.PromptResponseError("'help' prompt command output invalid")
for cmd_name in ret[1:]:
out.append(get_help_short(cmdr, "!" + cmd_name, ret))
return out

View file

@ -0,0 +1,224 @@
# Copyright 2024 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.
from __future__ import print_function
from binascii import crc32
import os
import struct
import sys
import traceback
import pebble.pulse2.exceptions
from .. import PebbleCommander, exceptions, parsers
from ..util import stm32_crc
class PebbleFirmwareBinaryInfo(object):
V1_STRUCT_VERSION = 1
V1_STRUCT_DEFINTION = [
('20s', 'build_id'),
('L', 'version_timestamp'),
('32s', 'version_tag'),
('8s', 'version_short'),
('?', 'is_recovery_firmware'),
('B', 'hw_platform'),
('B', 'metadata_version')
]
# The platforms which use a legacy defective crc32
LEGACY_CRC_PLATFORMS = [
0, # unknown (assume legacy)
1, # OneEV1
2, # OneEV2
3, # OneEV2_3
4, # OneEV2_4
5, # OnePointFive
6, # TwoPointFive
7, # SnowyEVT2
8, # SnowyDVT
9, # SpaldingEVT
10, # BobbyDVT
11, # Spalding
0xff, # OneBigboard
0xfe, # OneBigboard2
0xfd, # SnowyBigboard
0xfc, # SnowyBigboard2
0xfb, # SpaldingBigboard
]
def get_crc(self):
_, ext = os.path.splitext(self.path)
assert ext == '.bin', 'Can only calculate crc for .bin files'
with open(self.path, 'rb') as f:
image = f.read()
if self.hw_platform in self.LEGACY_CRC_PLATFORMS:
# use the legacy defective crc
return stm32_crc.crc32(image)
else:
# use a regular crc
return crc32(image) & 0xFFFFFFFF
def _get_footer_struct(self):
fmt = '<' + reduce(lambda s, t: s + t[0],
PebbleFirmwareBinaryInfo.V1_STRUCT_DEFINTION, '')
return struct.Struct(fmt)
def _get_footer_data_from_bin(self, path):
with open(path, 'rb') as f:
struct_size = self.struct.size
f.seek(-struct_size, 2)
footer_data = f.read()
return footer_data
def _parse_footer_data(self, footer_data):
z = zip(PebbleFirmwareBinaryInfo.V1_STRUCT_DEFINTION,
self.struct.unpack(footer_data))
return {entry[1]: data for entry, data in z}
def __init__(self, bin_path):
self.path = bin_path
self.struct = self._get_footer_struct()
_, ext = os.path.splitext(bin_path)
if ext != '.bin':
raise ValueError('Unexpected extension. Must be ".bin"')
footer_data = self._get_footer_data_from_bin(bin_path)
self.info = self._parse_footer_data(footer_data)
# Trim leading NULLS on the strings:
for k in ["version_tag", "version_short"]:
self.info[k] = self.info[k].rstrip("\x00")
def __str__(self):
return str(self.info)
def __repr__(self):
return self.info.__repr__()
def __getattr__(self, name):
if name in self.info:
return self.info[name]
raise AttributeError
# typedef struct ATTR_PACKED FirmwareDescription {
# uint32_t description_length;
# uint32_t firmware_length;
# uint32_t checksum;
# } FirmwareDescription;
FW_DESCR_FORMAT = '<III'
FW_DESCR_SIZE = struct.calcsize(FW_DESCR_FORMAT)
def _generate_firmware_description_struct(firmware_length, firmware_crc):
return struct.pack(FW_DESCR_FORMAT, FW_DESCR_SIZE, firmware_length, firmware_crc)
def insert_firmware_description_struct(input_binary, output_binary=None):
fw_bin_info = PebbleFirmwareBinaryInfo(input_binary)
with open(input_binary, 'rb') as inf:
fw_bin = inf.read()
fw_crc = fw_bin_info.get_crc()
return _generate_firmware_description_struct(len(fw_bin), fw_crc) + fw_bin
def _load(connection, image, progress, verbose, address):
image_crc = stm32_crc.crc32(image)
progress_cb = None
if progress or verbose:
def progress_cb(acked):
print('.' if acked else 'R', end='')
sys.stdout.flush()
if progress or verbose:
print('Erasing... ', end='')
sys.stdout.flush()
try:
connection.flash.erase(address, len(image))
except pebble.pulse2.exceptions.PulseException as e:
detail = ''.join(traceback.format_exception_only(type(e), e))
if verbose:
detail = '\n' + traceback.format_exc()
print('Erase failed! ' + detail)
return False
if progress or verbose:
print('done.')
sys.stdout.flush()
try:
retries = connection.flash.write(address, image,
progress_cb=progress_cb)
except pebble.pulse2.exceptions.PulseException as e:
detail = ''.join(traceback.format_exception_only(type(e), e))
if verbose:
detail = '\n' + traceback.format_exc()
print('Write failed! ' + detail)
return False
result_crc = connection.flash.crc(address, len(image))
if progress or verbose:
print()
if verbose:
print('Retries: %d' % retries)
return result_crc == image_crc
def load_firmware(connection, fin, progress, verbose, address=None):
if address is None:
# If address is unspecified, assume we want the prf address
_, address, length = connection.flash.query_region_geometry(
connection.flash.REGION_PRF)
address = int(address)
image = insert_firmware_description_struct(fin)
if _load(connection, image, progress, verbose, address):
connection.flash.finalize_region(
connection.flash.REGION_PRF)
return True
return False
def load_resources(connection, fin, progress, verbose):
_, address, length = connection.flash.query_region_geometry(
connection.flash.REGION_SYSTEM_RESOURCES)
with open(fin, 'rb') as f:
data = f.read()
assert len(data) <= length
if _load(connection, data, progress, verbose, address):
connection.flash.finalize_region(
connection.flash.REGION_SYSTEM_RESOURCES)
return True
return False
@PebbleCommander.command()
def image_resources(cmdr, pack='build/system_resources.pbpack'):
""" Image resources.
"""
load_resources(cmdr.connection, pack,
progress=cmdr.interactive, verbose=cmdr.interactive)
@PebbleCommander.command()
def image_firmware(cmdr, firm='build/prf/src/fw/tintin_fw.bin', address=None):
""" Image recovery firmware.
"""
if address is not None:
address = int(str(address), 0)
load_firmware(cmdr.connection, firm, progress=cmdr.interactive,
verbose=cmdr.interactive, address=address)

View file

@ -0,0 +1,22 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def audit_delay(cmdr):
""" Audit delay_us.
"""
return cmdr.send_prompt_command("audit delay")

View file

@ -0,0 +1,45 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def pfs_prepare(cmdr, size):
""" Prepare for file creation.
"""
size = int(str(size), 0)
if size <= 0:
raise exceptions.ParameterError('size out of range: %d' % size)
# TODO: I guess catch errors
ret = cmdr.send_prompt_command("pfs prepare %d" % size)
if not ret[0].startswith("Success"):
raise exceptions.PromptResponseError(ret)
# TODO: pfs-write
# Can't do it with pulse prompt :(
@PebbleCommander.command()
def pfs_litter(cmdr):
""" Fragment the filesystem.
Creates a bunch of fragmentation in the filesystem by creating a large
number of small files and only deleting a small number of them.
"""
ret = cmdr.send_prompt_command("litter pfs")
if not ret[0].startswith("OK "):
raise exceptions.PromptResponseError(ret)
return [ret[0][3:]]

View file

@ -0,0 +1,45 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def reset(cmdr):
""" Reset the device.
"""
cmdr.send_prompt_command("reset")
@PebbleCommander.command()
def crash(cmdr):
""" Crash the device.
"""
cmdr.send_prompt_command("crash")
@PebbleCommander.command()
def factory_reset(cmdr, fast=False):
""" Perform a factory reset.
If `fast` is specified as true or "fast", do a fast factory reset.
"""
if parsers.str2bool(fast, also_true=["fast"]):
fast = " fast"
else:
fast = ""
ret = cmdr.send_prompt_command("factory reset%s" % fast)
if ret:
raise exceptions.PromptResponseError(ret)

View file

@ -0,0 +1,38 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def version(cmdr):
""" Get version information.
"""
return cmdr.send_prompt_command("version")
@PebbleCommander.command()
def boot_bit_set(cmdr, bit, value):
""" Set some boot bits.
`bit` should be between 0 and 31.
`value` should be a boolean.
"""
bit = int(str(bit), 0)
value = int(parsers.str2bool(value))
if not 0 <= bit <= 31:
raise exceptions.ParameterError('bit index out of range: %d' % bit)
ret = cmdr.send_prompt_command("boot bit set %d %d" % (bit, value))
if not ret[0].startswith("OK"):
raise exceptions.PromptResponseError(ret)

View file

@ -0,0 +1,39 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def set_time(cmdr, new_time):
""" Set the time.
`new_time` should be in epoch seconds.
"""
new_time = int(str(new_time), 0)
if new_time < 1262304000:
raise exceptions.ParameterError("time must be later than 2010-01-01")
ret = cmdr.send_prompt_command("set time %s" % new_time)
if not ret[0].startswith("Time is now"):
raise exceptions.PromptResponseError(ret)
return ret
@PebbleCommander.command()
def timezone_clear(cmdr):
""" Clear timezone settings.
"""
ret = cmdr.send_prompt_command("timezone clear")
if ret:
raise exceptions.PromptResponseError(ret)

View file

@ -0,0 +1,29 @@
# Copyright 2024 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.
from .. import PebbleCommander, exceptions, parsers
@PebbleCommander.command()
def window_stack(cmdr):
""" Dump the window stack.
"""
return cmdr.send_prompt_command("window stack")
@PebbleCommander.command()
def modal_stack(cmdr):
""" Dump the modal stack.
"""
return cmdr.send_prompt_command("modal stack")

View file

@ -0,0 +1,2 @@
Application-layer PULSEv2 Protocols
===================================

View file

@ -0,0 +1,19 @@
# Copyright 2024 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.
# Public aliases for the classes that users will interact with directly.
from .bulkio import BulkIO
from .flash_imaging import FlashImaging
from .prompt import Prompt
from .streaming_logs import StreamingLogs

View file

@ -0,0 +1,451 @@
# Copyright 2024 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.
from __future__ import absolute_import
import collections
import logging
import struct
from ..exceptions import PebbleCommanderError
class ResponseParseError(PebbleCommanderError):
pass
class EraseError(PebbleCommanderError):
pass
class OpenCommand(object):
command_type = 1
command_struct = struct.Struct('<BB')
def __init__(self, domain, extra=None):
self.domain = domain
self.extra = extra
@property
def packet(self):
cmd = self.command_struct.pack(self.command_type, self.domain)
if self.extra:
cmd += self.extra
return cmd
class CloseCommand(object):
command_type = 2
command_struct = struct.Struct('<BB')
def __init__(self, fd):
self.fd = fd
@property
def packet(self):
return self.command_struct.pack(self.command_type, self.fd)
class ReadCommand(object):
command_type = 3
command_struct = struct.Struct('<BBII')
def __init__(self, fd, address, length):
self.fd = fd
self.address = address
self.length = length
@property
def packet(self):
return self.command_struct.pack(self.command_type, self.fd,
self.address, self.length)
class WriteCommand(object):
command_type = 4
command_struct = struct.Struct('<BBI')
header_size = command_struct.size
def __init__(self, fd, address, data):
self.fd = fd
self.address = address
self.data = data
@property
def packet(self):
return self.command_struct.pack(self.command_type, self.fd,
self.address) + self.data
class CRCCommand(object):
command_type = 5
command_struct = struct.Struct('<BBII')
def __init__(self, fd, address, length):
self.fd = fd
self.address = address
self.length = length
@property
def packet(self):
return self.command_struct.pack(self.command_type, self.fd,
self.address, self.length)
class StatCommand(object):
command_type = 6
command_struct = struct.Struct('<BB')
def __init__(self, fd):
self.fd = fd
@property
def packet(self):
return self.command_struct.pack(self.command_type, self.fd)
class EraseCommand(object):
command_type = 7
command_struct = struct.Struct('<BB')
def __init__(self, domain, extra=None):
self.domain = domain
self.extra = extra
@property
def packet(self):
cmd = self.command_struct.pack(self.command_type, self.domain)
if self.extra:
cmd += self.extra
return cmd
class OpenResponse(object):
response_type = 128
response_format = '<xB'
response_struct = struct.Struct(response_format)
header_size = response_struct.size
Response = collections.namedtuple('OpenResponse', 'fd')
@classmethod
def parse(cls, response):
response_type = ord(response[0])
if response_type != cls.response_type:
raise ResponseParseError('Unexpected response type: %r' % response_type)
return cls.Response._make(cls.response_struct.unpack(response))
class CloseResponse(object):
response_type = 129
response_format = '<xB'
response_struct = struct.Struct(response_format)
header_size = response_struct.size
Response = collections.namedtuple('CloseResponse', 'fd')
@classmethod
def parse(cls, response):
response_type = ord(response[0])
if response_type != cls.response_type:
raise ResponseParseError('Unexpected response type: %r' % response_type)
return cls.Response._make(cls.response_struct.unpack(response))
class ReadResponse(object):
response_type = 130
response_format = '<xBI'
response_struct = struct.Struct(response_format)
header_size = response_struct.size
Response = collections.namedtuple('ReadResponse', 'fd address data')
@classmethod
def parse(cls, response):
if ord(response[0]) != cls.response_type:
raise ResponseParseError('Unexpected response: %r' % response)
header = response[:cls.header_size]
body = response[cls.header_size:]
fd, address, = cls.response_struct.unpack(header)
return cls.Response(fd, address, body)
class WriteResponse(object):
response_type = 131
response_format = '<xBII'
response_struct = struct.Struct(response_format)
header_size = response_struct.size
Response = collections.namedtuple('WriteResponse', 'fd address length')
@classmethod
def parse(cls, response):
response_type = ord(response[0])
if response_type != cls.response_type:
raise ResponseParseError('Unexpected response type: %r' % response_type)
return cls.Response._make(cls.response_struct.unpack(response))
class CRCResponse(object):
response_type = 132
response_format = '<xBIII'
response_struct = struct.Struct(response_format)
header_size = response_struct.size
Response = collections.namedtuple('CRCResponse', 'fd address length crc')
@classmethod
def parse(cls, response):
response_type = ord(response[0])
if response_type != cls.response_type:
raise ResponseParseError('Unexpected response type: %r' % response_type)
return cls.Response._make(cls.response_struct.unpack(response))
class StatResponse(object):
response_type = 133
def __init__(self, name, format, fields):
self.name = name
self.struct = struct.Struct('<xBB' + format)
self.tuple = collections.namedtuple(name, 'fd flags ' + fields)
def parse(self, response):
response_type = ord(response[0])
if response_type != self.response_type:
raise ResponseParseError('Unexpected response type: %r' % response_type)
return self.tuple._make(self.struct.unpack(response))
def __repr__(self):
return 'StatResponse({self.name!r}, {self.struct!r}, {self.tuple!r})'.format(self=self)
class EraseResponse(object):
response_type = 134
response_format = '<xBb'
response_struct = struct.Struct(response_format)
header_size = response_struct.size
Response = collections.namedtuple('EraseResponse', 'domain status')
@classmethod
def parse(cls, response):
response_type = ord(response[0])
if response_type != cls.response_type:
raise ResponseParseError('Unexpected response type: %r' % response_type)
return cls.Response._make(cls.response_struct.unpack(response))
def enum(**enums):
return type('Enum', (), enums)
ReadDomains = enum(
MEMORY=1,
EXTERNAL_FLASH=2,
FRAMEBUFFER=3,
COREDUMP=4,
PFS=5
)
class PULSEIO_Base(object):
ERASE_FORMAT = None
STAT_FORMAT = None
DOMAIN = None
def __init__(self, socket, *args, **kwargs):
self.socket = socket
self.pos = 0
options = self._process_args(*args, **kwargs)
resp = self._send_and_receive(OpenCommand, OpenResponse, self.DOMAIN, options)
self.fd = resp.fd
def __enter__(self):
return self
def __exit__(self, type, value, traceback):
self.close()
@staticmethod
def _process_args(*args, **kwargs):
return ""
def _send_and_receive(self, cmd_type, resp_type, *args):
cmd = cmd_type(*args)
self.socket.send(cmd.packet)
ret = self.socket.receive(block=True)
return resp_type.parse(ret)
def close(self):
if self.fd is not None:
resp = self._send_and_receive(CloseCommand, CloseResponse, self.fd)
assert resp.fd == self.fd
self.fd = None
def seek_absolute(self, pos):
if pos < 0:
raise ValueError('Cannot seek to before start of file')
self.pos = pos
def seek_relative(self, num_bytes):
if (self.pos + num_bytes) < 0:
raise ValueError('Cannot seek to before start of file')
self.pos += num_bytes
@classmethod
def erase(cls, socket, *args):
if cls.ERASE_FORMAT == "raw":
options = "".join(args)
elif cls.ERASE_FORMAT:
options = struct.pack("<" + cls.ERASE_FORMAT, *args)
else:
raise NotImplementedError("Erase is not supported for domain %d" % cls.DOMAIN)
cmd = EraseCommand(cls.DOMAIN, options)
socket.send(cmd.packet)
status = 1
while status > 0:
ret = socket.receive(block=True)
resp = EraseResponse.parse(ret)
logging.debug("ERASE: domain %d status %d", resp.domain, resp.status)
status = resp.status
if status < 0:
raise EraseError(status)
def write(self, data):
if self.fd is None:
raise ValueError('Handle is not open')
mss = self.socket.mtu - WriteCommand.header_size
for offset in xrange(0, len(data), mss):
segment = data[offset:offset+mss]
resp = self._send_and_receive(WriteCommand, WriteResponse, self.fd, self.pos, segment)
assert resp.fd == self.fd
assert resp.address == self.pos
self.pos += len(segment)
def read(self, length):
if self.fd is None:
raise ValueError('Handle is not open')
cmd = ReadCommand(self.fd, self.pos, length)
self.socket.send(cmd.packet)
data = bytearray()
bytes_left = length
while bytes_left > 0:
packet = self.socket.receive(block=True)
fd, chunk_offset, chunk = ReadResponse.parse(packet)
assert fd == self.fd
data.extend(chunk)
bytes_left -= len(chunk)
return data
def crc(self, length):
if self.fd is None:
raise ValueError('Handle is not open')
resp = self._send_and_receive(CRCCommand, CRCResponse, self.fd, self.pos, length)
return resp.crc
def stat(self):
if self.fd is None:
raise ValueError('Handle is not open')
if not self.STAT_FORMAT:
raise NotImplementedError("Stat is not supported for domain %d" % self.DOMAIN)
return self._send_and_receive(StatCommand, self.STAT_FORMAT, self.fd)
class PULSEIO_Memory(PULSEIO_Base):
DOMAIN = ReadDomains.MEMORY
# uint32 for address, uint32 for length
ERASE_FORMAT = "II"
class PULSEIO_ExternalFlash(PULSEIO_Base):
DOMAIN = ReadDomains.EXTERNAL_FLASH
# uint32 for address, uint32 for length
ERASE_FORMAT = "II"
class PULSEIO_Framebuffer(PULSEIO_Base):
DOMAIN = ReadDomains.FRAMEBUFFER
STAT_FORMAT = StatResponse('FramebufferAttributes', 'BBBI', 'width height bpp length')
class PULSEIO_Coredump(PULSEIO_Base):
DOMAIN = ReadDomains.COREDUMP
STAT_FORMAT = StatResponse('CoredumpAttributes', 'BI', 'unread length')
ERASE_FORMAT = "I"
@staticmethod
def _process_args(slot):
return struct.pack("<I", slot)
class PULSEIO_PFS(PULSEIO_Base):
DOMAIN = ReadDomains.PFS
STAT_FORMAT = StatResponse('PFSFileAttributes', 'I', 'length')
ERASE_FORMAT = "raw"
OP_FLAG_READ = 1 << 0
OP_FLAG_WRITE = 1 << 1
OP_FLAG_OVERWRITE = 1 << 2
OP_FLAG_SKIP_HDR_CRC_CHECK = 1 << 3
OP_FLAG_USE_PAGE_CACHE = 1 << 4
@staticmethod
def _process_args(filename, mode='r', flags=0xFE, initial_size=0):
mode_num = PULSEIO_PFS.OP_FLAG_READ
if 'w' in mode:
mode_num |= PULSEIO_PFS.OP_FLAG_WRITE
return struct.pack("<BBI", mode_num, flags, initial_size) + filename
class BulkIO(object):
PROTOCOL_NUMBER = 0x3e21
DOMAIN_MAP = {
'pfs': PULSEIO_PFS,
'framebuffer': PULSEIO_Framebuffer
}
def __init__(self, link):
self.socket = link.open_socket('reliable', self.PROTOCOL_NUMBER)
def open(self, domain, *args, **kwargs):
return self.DOMAIN_MAP[domain](self.socket, *args, **kwargs)
def erase(self, domain, *args, **kwargs):
return self.DOMAIN_MAP[domain].erase(self.socket, *args, **kwargs)
def close(self):
self.socket.close()

View file

@ -0,0 +1,318 @@
# Copyright 2024 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.
from __future__ import absolute_import
import collections
import struct
import time
import pebble.pulse2.exceptions
from .. import exceptions
class EraseCommand(object):
command_type = 1
command_struct = struct.Struct('<BII')
response_type = 128
response_struct = struct.Struct('<xII?')
Response = collections.namedtuple(
'EraseResponse', 'address length complete')
def __init__(self, address, length):
self.address = address
self.length = length
@property
def packet(self):
return self.command_struct.pack(
self.command_type, self.address, self.length)
def parse_response(self, response):
if ord(response[0]) != self.response_type:
raise exceptions.ResponseParseError(
'Unexpected response: %r' % response)
unpacked = self.Response._make(self.response_struct.unpack(response))
if unpacked.address != self.address or unpacked.length != self.length:
raise exceptions.ResponseParseError(
'Response does not match command: '
'address=%#.08x legnth=%d (expected %#.08x, %d)' % (
unpacked.address, unpacked.length, self.address,
self.length))
return unpacked
class WriteCommand(object):
command_type = 2
command_struct = struct.Struct('<BI')
header_len = command_struct.size
def __init__(self, address, data):
self.address = address
self.data = data
@property
def packet(self):
header = self.command_struct.pack(self.command_type, self.address)
return header + self.data
class WriteResponse(object):
response_type = 129
response_struct = struct.Struct('<xII?')
Response = collections.namedtuple(
'WriteResponse', 'address length complete')
@classmethod
def parse(cls, response):
if ord(response[0]) != cls.response_type:
raise exceptions.ResponseParseError(
'Unexpected response: %r' % response)
return cls.Response._make(cls.response_struct.unpack(response))
class CrcCommand(object):
command_type = 3
command_struct = struct.Struct('<BII')
response_type = 130
response_struct = struct.Struct('<xIII')
Response = collections.namedtuple('CrcResponse', 'address length crc')
def __init__(self, address, length):
self.address = address
self.length = length
@property
def packet(self):
return self.command_struct.pack(self.command_type, self.address,
self.length)
def parse_response(self, response):
if ord(response[0]) != self.response_type:
raise exceptions.ResponseParseError(
'Unexpected response: %r' % response)
unpacked = self.Response._make(self.response_struct.unpack(response))
if unpacked.address != self.address or unpacked.length != self.length:
raise exceptions.ResponseParseError(
'Response does not match command: '
'address=%#.08x legnth=%d (expected %#.08x, %d)' % (
unpacked.address, unpacked.length, self.address,
self.length))
return unpacked
class QueryFlashRegionCommand(object):
command_type = 4
command_struct = struct.Struct('<BB')
REGION_PRF = 1
REGION_SYSTEM_RESOURCES = 2
response_type = 131
response_struct = struct.Struct('<xBII')
Response = collections.namedtuple(
'FlashRegionGeometry', 'region address length')
def __init__(self, region):
self.region = region
@property
def packet(self):
return self.command_struct.pack(self.command_type, self.region)
def parse_response(self, response):
if ord(response[0]) != self.response_type:
raise exceptions.ResponseParseError(
'Unexpected response: %r' % response)
unpacked = self.Response._make(self.response_struct.unpack(response))
if unpacked.address == 0 and unpacked.length == 0:
raise exceptions.RegionDoesNotExist(self.region)
return unpacked
class FinalizeFlashRegionCommand(object):
command_type = 5
command_struct = struct.Struct('<BB')
response_type = 132
response_struct = struct.Struct('<xB')
def __init__(self, region):
self.region = region
@property
def packet(self):
return self.command_struct.pack(self.command_type, self.region)
def parse_response(self, response):
if ord(response[0]) != self.response_type:
raise exceptions.ResponseParseError(
'Unexpected response: %r' % response)
region, = self.response_struct.unpack(response)
if region != self.region:
raise exceptions.ResponseParseError(
'Response does not match command: '
'response is for region %d (expected %d)' % (
region, self.region))
class FlashImaging(object):
PORT_NUMBER = 0x0002
RESP_BAD_CMD = 192
RESP_INTERNAL_ERROR = 193
REGION_PRF = QueryFlashRegionCommand.REGION_PRF
REGION_SYSTEM_RESOURCES = QueryFlashRegionCommand.REGION_SYSTEM_RESOURCES
def __init__(self, link):
self.socket = link.open_socket('best-effort', self.PORT_NUMBER)
def close(self):
self.socket.close()
def erase(self, address, length):
cmd = EraseCommand(address, length)
ack_received = False
retries = 0
while retries < 10:
if not ack_received:
self.socket.send(cmd.packet)
try:
packet = self.socket.receive(timeout=5 if ack_received else 1.5)
response = cmd.parse_response(packet)
ack_received = True
if response.complete:
return
except pebble.pulse2.exceptions.ReceiveQueueEmpty:
ack_received = False
retries += 1
continue
raise exceptions.CommandTimedOut
def write(self, address, data, max_retries=5, max_in_flight=5,
progress_cb=None):
mtu = self.socket.mtu - WriteCommand.header_len
assert(mtu > 0)
unsent = collections.deque()
for offset in xrange(0, len(data), mtu):
segment = data[offset:offset+mtu]
assert(len(segment))
seg_address = address + offset
unsent.appendleft(
(seg_address, WriteCommand(seg_address, segment), 0))
in_flight = collections.OrderedDict()
retries = 0
while unsent or in_flight:
try:
while True:
# Process ACKs (if any)
ack = WriteResponse.parse(
self.socket.receive(block=False))
try:
cmd, _, _ = in_flight[ack.address]
del in_flight[ack.address]
except KeyError:
for seg_address, cmd, retry_count in unsent:
if seg_address == ack.address:
if retry_count == 0:
# ACK for a segment we never sent?!
raise exceptions.WriteError(
'Received ACK for an unsent segment: '
'%#.08x' % ack.address)
# Got an ACK for a sent (but timed out) segment
unsent.remove((seg_address, cmd, retry_count))
break
else:
raise exceptions.WriteError(
'Received ACK for an unknown segment: '
'%#.08x' % ack.address)
if len(cmd.data) != ack.length:
raise exceptions.WriteError(
'ACK length %d != data length %d' % (
ack.length, len(cmd.data)))
assert(ack.complete)
if progress_cb:
progress_cb(True)
except pebble.pulse2.exceptions.ReceiveQueueEmpty:
pass
# Retry any in_flight writes where the ACK has timed out
to_retry = []
timeout_time = time.time() - 0.5
for (seg_address,
(cmd, send_time, retry_count)) in in_flight.iteritems():
if send_time > timeout_time:
# in_flight is an OrderedDict so iteration is in
# chronological order.
break
if retry_count >= max_retries:
raise exceptions.WriteError(
'Segment %#.08x exceeded the max retry count (%d)' % (
seg_address, max_retries))
# Enqueue the packet again to resend later.
del in_flight[seg_address]
unsent.appendleft((seg_address, cmd, retry_count+1))
retries += 1
if progress_cb:
progress_cb(False)
# Send out fresh segments
try:
while len(in_flight) < max_in_flight:
seg_address, cmd, retry_count = unsent.pop()
self.socket.send(cmd.packet)
in_flight[cmd.address] = (cmd, time.time(), retry_count)
except IndexError:
pass
# Give other threads a chance to run
time.sleep(0)
return retries
def _command_and_response(self, cmd, timeout=0.5):
for attempt in xrange(5):
self.socket.send(cmd.packet)
try:
packet = self.socket.receive(timeout=timeout)
return cmd.parse_response(packet)
except pebble.pulse2.exceptions.ReceiveQueueEmpty:
pass
raise exceptions.CommandTimedOut
def crc(self, address, length):
cmd = CrcCommand(address, length)
return self._command_and_response(cmd, timeout=1).crc
def query_region_geometry(self, region):
cmd = QueryFlashRegionCommand(region)
return self._command_and_response(cmd)
def finalize_region(self, region):
cmd = FinalizeFlashRegionCommand(region)
return self._command_and_response(cmd)

View file

@ -0,0 +1,78 @@
# Copyright 2024 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.
from __future__ import absolute_import
import collections
import struct
from datetime import datetime
import pebble.pulse2.exceptions
from .. import exceptions
class Prompt(object):
PORT_NUMBER = 0x3e20
def __init__(self, link):
self.socket = link.open_socket('reliable', self.PORT_NUMBER)
def command_and_response(self, command_string, timeout=20):
log = []
self.socket.send(bytes(command_string))
is_done = False
while not is_done:
try:
response = PromptResponse.parse(
self.socket.receive(timeout=timeout))
if response.is_done_response:
is_done = True
elif response.is_message_response:
log.append(response.message)
except pebble.pulse2.exceptions.ReceiveQueueEmpty:
raise exceptions.CommandTimedOut
return log
def close(self):
self.socket.close()
class PromptResponse(collections.namedtuple('PromptResponse',
'response_type timestamp message')):
DONE_RESPONSE = 101
MESSAGE_RESPONSE = 102
response_struct = struct.Struct('<BQ')
@property
def is_done_response(self):
return self.response_type == self.DONE_RESPONSE
@property
def is_message_response(self):
return self.response_type == self.MESSAGE_RESPONSE
@classmethod
def parse(cls, response):
result = cls.response_struct.unpack(response[:cls.response_struct.size])
response_type = result[0]
timestamp = datetime.fromtimestamp(result[1] / 1000.0)
message = response[cls.response_struct.size:]
return cls(response_type, timestamp, message)

View file

@ -0,0 +1,67 @@
# Copyright 2024 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.
from __future__ import absolute_import
import collections
import struct
from datetime import datetime
class LogMessage(collections.namedtuple('LogMessage',
'log_level task timestamp file_name line_number message')):
__slots__ = ()
response_struct = struct.Struct('<c16sccQH')
def __str__(self):
msec_timestamp = self.timestamp.strftime("%H:%M:%S.%f")[:-3]
template = ('{self.log_level} {self.task} {msec_timestamp} '
'{self.file_name}:{self.line_number}> {self.message}')
return template.format(self=self, msec_timestamp=msec_timestamp)
@classmethod
def parse(cls, packet):
result = cls.response_struct.unpack(packet[:cls.response_struct.size])
msg = packet[cls.response_struct.size:]
log_level = result[2]
task = result[3]
timestamp = datetime.fromtimestamp(result[4] / 1000.0)
file_name = result[1].split('\x00', 1)[0] # NUL terminated
line_number = result[5]
return cls(log_level, task, timestamp, file_name, line_number, msg)
class StreamingLogs(object):
'''App for receiving log messages streamed by the firmware.
'''
PORT_NUMBER = 0x0003
def __init__(self, interface):
try:
self.socket = interface.simplex_transport.open_socket(
self.PORT_NUMBER)
except AttributeError:
raise TypeError('LoggingApp must be bound directly '
'to an Interface, not a Link')
def receive(self, block=True, timeout=None):
return LogMessage.parse(self.socket.receive(block, timeout))
def close(self):
self.socket.close()

View file

@ -0,0 +1,167 @@
# Copyright 2024 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.
from __future__ import absolute_import
import re
import threading
import tokenize
import types
from pebble import pulse2
from . import apps
class Pulse2ConnectionAdapter(object):
'''An adapter for the pulse2 API to look enough like pulse.Connection
to make PebbleCommander work...ish.
Prompt will break spectacularly if the firmware reboots or the link
state otherwise changes. Commander itself needs to be modified to be
link-state aware.
'''
def __init__(self, interface):
self.interface = interface
self.logging = apps.StreamingLogs(interface)
link = interface.get_link()
self.prompt = apps.Prompt(link)
self.flash = apps.FlashImaging(link)
def close(self):
self.interface.close()
class PebbleCommander(object):
""" Pebble Commander.
Implements everything for interfacing with PULSE things.
"""
def __init__(self, tty=None, interactive=False, capfile=None):
if capfile is not None:
interface = pulse2.Interface.open_dbgserial(
url=tty, capture_stream=open(capfile, 'wb'))
else:
interface = pulse2.Interface.open_dbgserial(url=tty)
try:
self.connection = Pulse2ConnectionAdapter(interface)
except:
interface.close()
raise
self.interactive = interactive
self.log_listeners_lock = threading.Lock()
self.log_listeners = []
# Start the logging thread
self.log_thread = threading.Thread(target=self._start_logging)
self.log_thread.daemon = True
self.log_thread.start()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
@classmethod
def command(cls, name=None):
""" Registers a command.
`name` is the command name. If `name` is unspecified, name will be the function name
with underscores converted to hyphens.
The convention for `name` is to separate words with a hyphen. The function name
will be the same as `name` with hyphens replaced with underscores.
Example: `click-short` will result in a PebbleCommander.click_short function existing.
`fn` should return an array of strings (or None), and take the current
`PebbleCommander` as the first argument, and the rest of the argument strings
as subsequent arguments. For errors, `fn` should throw an exception.
# TODO: Probably make the return something structured instead of stringly typed.
"""
def decorator(fn):
# Story time:
# <cory> Things are fine as long as you only read from `name`, but assigning to `name`
# creates a new local which shadows the outer scope's variable, even though it's
# only assigned later on in the block
# <cory> You could work around this by doing something like `name_ = name` and using
# `name_` in the `decorator` scope
cmdname = name
if not cmdname:
cmdname = fn.__name__.replace('_', '-')
funcname = cmdname.replace('-', '_')
if not re.match(tokenize.Name + '$', funcname):
raise ValueError("command name %s isn't a valid name" % funcname)
if hasattr(cls, funcname):
raise ValueError('function name %s clashes with existing attribute' % funcname)
fn.is_command = True
fn.name = cmdname
method = types.MethodType(fn, None, cls)
setattr(cls, funcname, method)
return fn
return decorator
def close(self):
self.connection.close()
def _start_logging(self):
""" Thread to handle logging messages.
"""
while True:
try:
msg = self.connection.logging.receive()
except pulse2.exceptions.SocketClosed:
break
with self.log_listeners_lock:
# TODO: Buffer log messages if no listeners attached?
for listener in self.log_listeners:
try:
listener(msg)
except:
pass
def attach_log_listener(self, listener):
""" Attaches a listener for log messages.
Function takes message and returns are ignored.
"""
with self.log_listeners_lock:
self.log_listeners.append(listener)
def detach_log_listener(self, listener):
""" Removes a listener that was added with `attach_log_listener`
"""
with self.log_listeners_lock:
self.log_listeners.remove(listener)
def send_prompt_command(self, cmd):
""" Send a prompt command string.
Unfortunately this is indeed stringly typed, a better solution is necessary.
"""
return self.connection.prompt.command_and_response(cmd)
def get_command(self, command):
try:
fn = getattr(self, command.replace('-', '_'))
if fn.is_command:
return fn
except AttributeError:
# Method doesn't exist, or isn't a command.
pass
return None

View file

@ -0,0 +1,40 @@
# Copyright 2024 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.
class PebbleCommanderError(Exception):
pass
class ParameterError(PebbleCommanderError):
pass
class PromptResponseError(PebbleCommanderError):
pass
class ResponseParseError(PebbleCommanderError):
pass
class RegionDoesNotExist(PebbleCommanderError):
pass
class CommandTimedOut(PebbleCommanderError):
pass
class WriteError(PebbleCommanderError):
pass

View file

@ -0,0 +1,137 @@
# Copyright 2024 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.
from __future__ import absolute_import
import shlex
import traceback
from log_hashing.logdehash import LogDehash
import prompt_toolkit
from .commander import PebbleCommander
class InteractivePebbleCommander(object):
""" Interactive Pebble Commander.
Most/all UI implementations should either use this directly or sub-class it.
"""
def __init__(self, loghash_path=None, tty=None, capfile=None):
self.cmdr = PebbleCommander(tty=tty, interactive=True, capfile=capfile)
if loghash_path is None:
loghash_path = "build/src/fw/loghash_dict.json"
self.dehasher = LogDehash(loghash_path)
self.cmdr.attach_log_listener(self.log_listener)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def __del__(self):
self.close()
def close(self):
try:
self.cmdr.close()
except:
pass
def attach_prompt_toolkit(self):
""" Attaches prompt_toolkit things
"""
self.history = prompt_toolkit.history.InMemoryHistory()
self.cli = prompt_toolkit.CommandLineInterface(
application=prompt_toolkit.shortcuts.create_prompt_application(u"> ",
history=self.history),
eventloop=prompt_toolkit.shortcuts.create_eventloop())
self.patch_context = self.cli.patch_stdout_context(raw=True)
self.patch_context.__enter__()
def log_listener(self, msg):
""" This is called on every incoming log message.
`msg` is the raw log message class, without any dehashing.
Subclasses should override this probably.
"""
line_dict = self.dehasher.dehash(msg)
line = self.dehasher.commander_format_line(line_dict)
print line
def dispatch_command(self, string):
""" Dispatches a command string.
Subclasses should not override this.
"""
args = shlex.split(string)
# Starting with '!' passes the rest of the line directly to prompt.
# Otherwise we try to run a command; if that fails, the line goes to prompt.
if string.startswith("!"):
string = string[1:] # Chop off the '!' marker
else:
cmd = self.cmdr.get_command(args[0])
if cmd: # If we provide the command, run it.
return cmd(*args[1:])
return self.cmdr.send_prompt_command(string)
def input_handle(self, string):
""" Handles an input line.
Generally the flow is to handle any UI-specific commands, then pass on to
dispatch_command.
Subclasses should override this probably.
"""
# Handle "quit" strings
if string in ["exit", "q", "quit"]:
return False
try:
resp = self.dispatch_command(string)
if resp is not None:
print "\x1b[1m" + '\n'.join(resp) + "\x1b[m"
except:
print "An error occurred!"
traceback.print_exc()
return True
def get_command(self):
""" Get a command input line.
If there is no line, return an empty string or None.
This may block.
Subclasses should override this probably.
"""
if self.cli is None:
self.attach_prompt_toolkit()
doc = self.cli.run(reset_current_buffer=True)
if doc:
return doc.text
else:
return None
def command_loop(self):
""" The main command loop.
Subclasses could override this, but it's probably not useful to do.
"""
while True:
try:
cmd = self.get_command()
if cmd and not self.input_handle(cmd):
break
except (KeyboardInterrupt, EOFError):
break

View file

@ -0,0 +1,38 @@
# Copyright 2024 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.
from __future__ import absolute_import
import re
from . import exceptions
def str2bool(s, also_true=[], also_false=[]):
s = str(s).lower()
if s in ("yes", "on", "t", "1", "true", "enable") or s in also_true:
return True
if s in ("no", "off", "f", "0", "false", "disable") or s in also_false:
return False
raise exceptions.ParameterError("%s not a valid bool string." % s)
def str2mac(s):
s = str(s)
if not re.match(r'[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}', s):
raise exceptions.ParameterError('%s is not a valid MAC address' % s)
mac = []
for byte in str(s).split(':'):
mac.append(int(byte, 16))
return tuple(mac)

View file

@ -0,0 +1,14 @@
# Copyright 2024 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.

View file

@ -0,0 +1,65 @@
# Copyright 2024 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.
CRC_POLY = 0x04C11DB7
def precompute_table(bits):
lookup_table = []
for i in xrange(2**bits):
rr = i << (32 - bits)
for x in xrange(bits):
if rr & 0x80000000:
rr = (rr << 1) ^ CRC_POLY
else:
rr <<= 1
lookup_table.append(rr & 0xffffffff)
return lookup_table
lookup_table = precompute_table(8)
def process_word(data, crc=0xffffffff):
if (len(data) < 4):
# The CRC data is "padded" in a very unique and confusing fashion.
data = data[::-1] + '\0' * (4 - len(data))
for char in reversed(data):
b = ord(char)
crc = ((crc << 8) ^ lookup_table[(crc >> 24) ^ b]) & 0xffffffff
return crc
def process_buffer(buf, c=0xffffffff):
word_count = (len(buf) + 3) / 4
crc = c
for i in xrange(word_count):
crc = process_word(buf[i * 4 : (i + 1) * 4], crc)
return crc
def crc32(data):
return process_buffer(data)
if __name__ == '__main__':
import sys
assert(0x89f3bab2 == process_buffer("123 567 901 34"))
assert(0xaff19057 == process_buffer("123456789"))
assert(0x519b130 == process_buffer("\xfe\xff\xfe\xff"))
assert(0x495e02ca == process_buffer("\xfe\xff\xfe\xff\x88"))
print "All tests passed!"
if len(sys.argv) >= 2:
b = open(sys.argv[1]).read()
crc = crc32(b)
print "%u or 0x%x" % (crc, crc)

View file

@ -0,0 +1,56 @@
# Copyright 2024 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.
# Always prefer setuptools over distutils
from setuptools import setup, find_packages
# To use a consistent encoding
from codecs import open
from os import path
import sys
here = path.abspath(path.dirname(__file__))
# Get the long description from the README file
with open(path.join(here, 'README.rst'), encoding='utf-8') as f:
long_description = f.read()
setup(
name='pebble.commander',
version='0.0.11',
description='Pebble Commander',
long_description=long_description,
url='https://github.com/pebble/pebble-commander',
author='Pebble Technology Corporation',
author_email='cory@pebble.com',
packages=find_packages(exclude=['contrib', 'docs', 'tests']),
namespace_packages = ['pebble'],
install_requires=[
'pebble.pulse2>=0.0.7,<1',
],
extras_require = {
'Interactive': [
'pebble.loghash>=2.6',
'prompt_toolkit>=0.55',
],
},
entry_points={
'console_scripts': [
'pebble-commander = pebble.commander.__main__:main [Interactive]',
],
},
)