mirror of
https://github.com/google/pebble.git
synced 2025-06-24 18:16:15 +00:00
Import of the watch repository from Pebble
This commit is contained in:
commit
3b92768480
10334 changed files with 2564465 additions and 0 deletions
16
tools/commander/__init__.py
Normal file
16
tools/commander/__init__.py
Normal 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, InteractivePebbleCommander
|
||||
from . import _commands
|
27
tools/commander/_commands/__init__.py
Normal file
27
tools/commander/_commands/__init__.py
Normal 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
|
84
tools/commander/_commands/app.py
Normal file
84
tools/commander/_commands/app.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
# 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)
|
35
tools/commander/_commands/battery.py
Normal file
35
tools/commander/_commands/battery.py
Normal 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")
|
82
tools/commander/_commands/bluetooth.py
Normal file
82
tools/commander/_commands/bluetooth.py
Normal 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)
|
58
tools/commander/_commands/clicks.py
Normal file
58
tools/commander/_commands/clicks.py
Normal 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)
|
61
tools/commander/_commands/flash.py
Normal file
61
tools/commander/_commands/flash.py
Normal 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:]]
|
133
tools/commander/_commands/help.py
Normal file
133
tools/commander/_commands/help.py
Normal 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
|
35
tools/commander/_commands/imaging.py
Normal file
35
tools/commander/_commands/imaging.py
Normal 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 image_resources(cmdr, pack='build/system_resources.pbpack'):
|
||||
""" Image resources.
|
||||
"""
|
||||
import pulse_flash_imaging
|
||||
pulse_flash_imaging.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.
|
||||
"""
|
||||
import pulse_flash_imaging
|
||||
if address is not None:
|
||||
address = int(str(address), 0)
|
||||
pulse_flash_imaging.load_firmware(cmdr.connection, firm,
|
||||
verbose=cmdr.interactive, address=address)
|
22
tools/commander/_commands/misc.py
Normal file
22
tools/commander/_commands/misc.py
Normal 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")
|
45
tools/commander/_commands/pfs.py
Normal file
45
tools/commander/_commands/pfs.py
Normal 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:]]
|
45
tools/commander/_commands/resets.py
Normal file
45
tools/commander/_commands/resets.py
Normal 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)
|
38
tools/commander/_commands/system.py
Normal file
38
tools/commander/_commands/system.py
Normal 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)
|
39
tools/commander/_commands/time.py
Normal file
39
tools/commander/_commands/time.py
Normal 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)
|
86
tools/commander/_commands/windows.py
Normal file
86
tools/commander/_commands/windows.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
# 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 tempfile
|
||||
|
||||
import png
|
||||
from bitarray import bitarray
|
||||
|
||||
from .. import PebbleCommander, exceptions, parsers
|
||||
|
||||
|
||||
def chunks(l, n):
|
||||
for i in xrange(0, len(l), n):
|
||||
yield l[i:i+n]
|
||||
|
||||
|
||||
def convert_8bpp(data):
|
||||
ret = []
|
||||
for pixel in data:
|
||||
pixel = ord(pixel)
|
||||
red, green, blue = (((pixel >> n) & 0b11) * 255 / 3 for n in (4, 2, 0))
|
||||
ret.extend((red, green, blue))
|
||||
return ret
|
||||
|
||||
|
||||
def convert_1bpp(data, width):
|
||||
# Chop off the unused bytes at the end of each row
|
||||
bytes_per_row = (width / 32 + 1) * 4
|
||||
data = ''.join(c[:width/8] for c in chunks(data, bytes_per_row))
|
||||
ba = bitarray(endian='little')
|
||||
ba.frombytes(data)
|
||||
return ba.tolist()
|
||||
|
||||
|
||||
def framebuffer_to_png(data, png_path, width, bpp):
|
||||
if bpp == 1:
|
||||
data = convert_1bpp(data, width)
|
||||
channels = 'L'
|
||||
elif bpp == 8:
|
||||
data = convert_8bpp(data)
|
||||
channels = 'RGB'
|
||||
|
||||
data = list(chunks(data, width*len(channels)))
|
||||
|
||||
png.from_array(data, mode='%s;%d' % (channels, bpp)).save(png_path)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@PebbleCommander.command()
|
||||
def screenshot(cmdr, filename=None):
|
||||
""" Take a screenshot.
|
||||
"""
|
||||
|
||||
if filename is None:
|
||||
filename = tempfile.mktemp(suffix='.png')
|
||||
|
||||
with cmdr.connection.bulkio.open('framebuffer') as handle:
|
||||
attrs = handle.stat()
|
||||
data = str(handle.read(attrs.length))
|
||||
|
||||
framebuffer_to_png(data, filename, attrs.width, attrs.bpp)
|
||||
return filename
|
266
tools/commander/commander.py
Normal file
266
tools/commander/commander.py
Normal file
|
@ -0,0 +1,266 @@
|
|||
#!/usr/bin/env python
|
||||
# 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 datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import readline
|
||||
import shlex
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import tokenize
|
||||
import traceback
|
||||
import types
|
||||
import unicodedata as ud
|
||||
|
||||
import prompt_toolkit
|
||||
|
||||
from log_hashing.logdehash import LogDehash
|
||||
import pulse
|
||||
|
||||
|
||||
class PebbleCommander(object):
|
||||
""" Pebble Commander.
|
||||
Implements everything for interfacing with PULSE things.
|
||||
"""
|
||||
|
||||
def __init__(self, tty=None, interactive=False):
|
||||
self.connection = pulse.socket.Connection.open_dbgserial(
|
||||
url=tty, infinite_reconnect=interactive)
|
||||
self.connection.change_baud_rate(921600)
|
||||
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()
|
||||
|
||||
def __del__(self):
|
||||
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):
|
||||
try:
|
||||
self.connection.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
def _start_logging(self):
|
||||
""" Thread to handle logging messages.
|
||||
"""
|
||||
while True:
|
||||
msg = self.connection.logging.receive()
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
self.cmdr = PebbleCommander(tty=tty, interactive=True)
|
||||
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
|
24
tools/commander/exceptions.py
Normal file
24
tools/commander/exceptions.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
# 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
|
36
tools/commander/parsers.py
Normal file
36
tools/commander/parsers.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
# 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 re
|
||||
|
||||
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)
|
Loading…
Add table
Add a link
Reference in a new issue