From a7d56504be6c720bc4c02109d980f254cef54c2a Mon Sep 17 00:00:00 2001 From: Mike Kinney Date: Fri, 14 Jan 2022 16:36:53 -0800 Subject: [PATCH] handle snake_case or camelCase --- exampleConfig.yaml | 16 +++++ example_config.yaml | 1 + meshtastic/__main__.py | 107 ++++++++++++++++++++++++------- meshtastic/globals.py | 12 ++++ meshtastic/node.py | 2 - meshtastic/tests/test_main.py | 115 +++++++++++++++++++++++++++++++++- meshtastic/tests/test_util.py | 22 ++++++- meshtastic/util.py | 14 +++++ 8 files changed, 263 insertions(+), 26 deletions(-) create mode 100644 exampleConfig.yaml diff --git a/exampleConfig.yaml b/exampleConfig.yaml new file mode 100644 index 0000000..c4539fa --- /dev/null +++ b/exampleConfig.yaml @@ -0,0 +1,16 @@ +# example config using camelCase keys +owner: Bob TBeam + +channelUrl: https://www.meshtastic.org/d/#CgUYAyIBAQ + +location: + lat: 35.88888 + lon: -93.88888 + alt: 304 + +userPrefs: + region: 1 + isAlwaysPowered: 'true' + sendOwnerInterval: 2 + screenOnSecs: 31536000 + waitBluetoothSecs: 31536000 diff --git a/example_config.yaml b/example_config.yaml index 231b465..9008512 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -1,3 +1,4 @@ +# example configuration file with snake_case keys owner: Bob TBeam channel_url: https://www.meshtastic.org/d/#CgUYAyIBAQ diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 9ea52f4..a4f2e11 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -20,7 +20,6 @@ from meshtastic import portnums_pb2, channel_pb2, radioconfig_pb2 from meshtastic.globals import Globals from meshtastic.__init__ import BROADCAST_ADDR - def onReceive(packet, interface): """Callback invoked when a packet arrives""" our_globals = Globals.getInstance() @@ -56,14 +55,26 @@ def onConnection(interface, topic=pub.AUTO_TOPIC): # pylint: disable=W0613 def getPref(attributes, name): """Get a channel or preferences value""" + camel_name = meshtastic.util.snake_to_camel(name) + # Note: protobufs has the keys in snake_case, so snake internally + snake_name = meshtastic.util.camel_to_snake(name) + logging.debug(f'snake_name:{snake_name} camel_name:{camel_name}') + logging.debug(f'use camel:{Globals.getInstance().get_camel_case()}') + objDesc = attributes.DESCRIPTOR - field = objDesc.fields_by_name.get(name) + field = objDesc.fields_by_name.get(snake_name) if not field: - print(f"{attributes.__class__.__name__} does not have an attribute called {name}, so you can not get it.") + if Globals.getInstance().get_camel_case(): + print(f"{attributes.__class__.__name__} does not have an attribute called {camel_name}, so you can not get it.") + else: + print(f"{attributes.__class__.__name__} does not have an attribute called {snake_name}, so you can not get it.") print(f"Choices in sorted order are:") names = [] for f in objDesc.fields: - names.append(f'{f.name}') + tmp_name = f'{f.name}' + if Globals.getInstance().get_camel_case(): + tmp_name = meshtastic.util.snake_to_camel(tmp_name) + names.append(tmp_name) for temp_name in sorted(names): print(f" {temp_name}") return @@ -71,28 +82,45 @@ def getPref(attributes, name): # okay - try to read the value try: try: - val = getattr(attributes, name) + val = getattr(attributes, snake_name) except TypeError: # The getter didn't like our arg type guess try again as a string - val = getattr(attributes, name) + val = getattr(attributes, snake_name) # succeeded! - print(f"{name}: {str(val)}") + if Globals.getInstance().get_camel_case(): + print(f"{camel_name}: {str(val)}") + logging.debug(f"{camel_name}: {str(val)}") + else: + print(f"{snake_name}: {str(val)}") + logging.debug(f"{snake_name}: {str(val)}") except Exception as ex: - print(f"Can't get {name} due to {ex}") + if Globals.getInstance().get_camel_case(): + print(f"Can't get {camel_name} due to {ex}") + else: + print(f"Can't get {snake_name} due to {ex}") def setPref(attributes, name, valStr): """Set a channel or preferences value""" + snake_name = meshtastic.util.camel_to_snake(name) + camel_name = meshtastic.util.snake_to_camel(name) + objDesc = attributes.DESCRIPTOR - field = objDesc.fields_by_name.get(name) + field = objDesc.fields_by_name.get(snake_name) if not field: - print(f"{attributes.__class__.__name__} does not have an attribute called {name}, so you can not set it.") + if Globals.getInstance().get_camel_case(): + print(f"{attributes.__class__.__name__} does not have an attribute called {camel_name}, so you can not set it.") + else: + print(f"{attributes.__class__.__name__} does not have an attribute called {snake_name}, so you can not set it.") print(f"Choices in sorted order are:") names = [] for f in objDesc.fields: - names.append(f'{f.name}') + tmp_name = f'{f.name}' + if Globals.getInstance().get_camel_case(): + tmp_name = meshtastic.util.snake_to_camel(tmp_name) + names.append(tmp_name) for temp_name in sorted(names): print(f" {temp_name}") return @@ -107,11 +135,17 @@ def setPref(attributes, name, valStr): if e: val = e.number else: - print(f"{name} does not have an enum called {val}, so you can not set it.") + if Globals.getInstance().get_camel_case(): + print(f"{snake_name} does not have an enum called {val}, so you can not set it.") + else: + print(f"{camel_name} does not have an enum called {val}, so you can not set it.") print(f"Choices in sorted order are:") names = [] for f in enumType.values: - names.append(f'{f.name}') + tmp_name = f'{f.name}' + if Globals.getInstance().get_camel_case(): + tmp_name = meshtastic.util.snake_to_camel(tmp_name) + names.append(name) for temp_name in sorted(names): print(f" {temp_name}") return @@ -119,15 +153,21 @@ def setPref(attributes, name, valStr): # okay - try to read the value try: try: - setattr(attributes, name, val) + setattr(attributes, snake_name, val) except TypeError: # The setter didn't like our arg type guess try again as a string - setattr(attributes, name, valStr) + setattr(attributes, snake_name, valStr) # succeeded! - print(f"Set {name} to {valStr}") + if Globals.getInstance().get_camel_case(): + print(f"Set {camel_name} to {valStr}") + else: + print(f"Set {snake_name} to {valStr}") except Exception as ex: - print(f"Can't set {name} due to {ex}") + if Globals.getInstance().get_camel_case(): + print(f"Can't set {camel_name} due to {ex}") + else: + print(f"Can't set {snake_name} due to {ex}") def onConnected(interface): @@ -311,6 +351,10 @@ def onConnected(interface): print("Setting channel url to", configuration['channel_url']) interface.getNode(args.dest).setURL(configuration['channel_url']) + if 'channelUrl' in configuration: + print("Setting channel url to", configuration['channelUrl']) + interface.getNode(args.dest).setURL(configuration['channelUrl']) + if 'location' in configuration: alt = 0 lat = 0.0 @@ -340,6 +384,13 @@ def onConnected(interface): print("Writing modified preferences to device") interface.getNode(args.dest).writeConfig() + if 'userPrefs' in configuration: + prefs = interface.getNode(args.dest).radioConfig.preferences + for pref in configuration['userPrefs']: + setPref(prefs, pref, str(configuration['userPrefs'][pref])) + print("Writing modified preferences to device") + interface.getNode(args.dest).writeConfig() + if args.export_config: # export the configuration (the opposite of '--configure') closeNow = True @@ -548,7 +599,10 @@ def export_config(interface): if owner: config += f"owner: {owner}\n\n" if channel_url: - config += f"channel_url: {channel_url}\n\n" + if Globals.getInstance().get_camel_case(): + config += f"channelUrl: {channel_url}\n\n" + else: + config += f"channel_url: {channel_url}\n\n" if lat or lon or alt: config += "location:\n" if lat: @@ -561,9 +615,16 @@ def export_config(interface): preferences = f'{interface.localNode.radioConfig.preferences}' prefs = preferences.splitlines() if prefs: - config += "user_prefs:\n" + if Globals.getInstance().get_camel_case(): + config += "userPrefs:\n" + else: + config += "user_prefs:\n" for pref in prefs: - config += f" {meshtastic.util.quoteBooleans(pref)}\n" + if Globals.getInstance().get_camel_case(): + # Note: This may not work if the value has '_' + config += f" {meshtastic.util.snake_to_camel(meshtastic.util.quoteBooleans(pref))}\n" + else: + config += f" {meshtastic.util.quoteBooleans(pref)}\n" print(config) return config @@ -692,10 +753,12 @@ def initParser(): action="store_true") parser.add_argument( - "--get", help="Get a preferences field. Use an invalid field such as '0' to get a list of all fields.", nargs=1, action='append') + "--get", help=("Get a preferences field. Use an invalid field such as '0' to get a list of all fields." + " Can use either snake_case or camelCase format. (ex: 'ls_secs' or 'lsSecs')"), + nargs=1, action='append') parser.add_argument( - "--set", help="Set a preferences field", nargs=2, action='append') + "--set", help="Set a preferences field. Can use either snake_case or camelCase format. (ex: 'ls_secs' or 'lsSecs')", nargs=2, action='append') parser.add_argument( "--seturl", help="Set a channel URL", action="store") diff --git a/meshtastic/globals.py b/meshtastic/globals.py index 6e5125b..a8a2bc7 100644 --- a/meshtastic/globals.py +++ b/meshtastic/globals.py @@ -30,6 +30,8 @@ class Globals: self.channel_index = None self.logfile = None self.tunnelInstance = None + # TODO: to migrate to camel_case for v1.3 change this value to True + self.camel_case = False def reset(self): """Reset all of our globals. If you add a member, add it to this method, too.""" @@ -38,6 +40,8 @@ class Globals: self.channel_index = None self.logfile = None self.tunnelInstance = None + # TODO: to migrate to camel_case for v1.3 change this value to True + self.camel_case = False # setters def set_args(self, args): @@ -60,6 +64,10 @@ class Globals: """Set the tunnelInstance""" self.tunnelInstance = tunnelInstance + def set_camel_case(self): + """Force using camelCase for things like prefs/set/set""" + self.camel_case = True + # getters def get_args(self): """Get args""" @@ -80,3 +88,7 @@ class Globals: def get_tunnelInstance(self): """Get tunnelInstance""" return self.tunnelInstance + + def get_camel_case(self): + """Get whether or not to use camelCase""" + return self.camel_case diff --git a/meshtastic/node.py b/meshtastic/node.py index 2c9cbd0..a08523a 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -8,8 +8,6 @@ from meshtastic import portnums_pb2, apponly_pb2, admin_pb2, channel_pb2 from meshtastic.util import pskToString, stripnl, Timeout, our_exit, fromPSK - - class Node: """A model of a (local or remote) node in the mesh diff --git a/meshtastic/tests/test_main.py b/meshtastic/tests/test_main.py index 84721f3..d28fbd8 100644 --- a/meshtastic/tests/test_main.py +++ b/meshtastic/tests/test_main.py @@ -781,6 +781,28 @@ def test_main_set_valid(capsys): mo.assert_called() +@pytest.mark.unit +@pytest.mark.usefixtures("reset_globals") +def test_main_set_valid_camel_case(capsys): + """Test --set with valid field""" + sys.argv = ['', '--set', 'wifi_ssid', 'foo'] + Globals.getInstance().set_args(sys.argv) + Globals.getInstance().set_camel_case() + + mocked_node = MagicMock(autospec=Node) + + iface = MagicMock(autospec=SerialInterface) + iface.getNode.return_value = mocked_node + + with patch('meshtastic.serial_interface.SerialInterface', return_value=iface) as mo: + main() + out, err = capsys.readouterr() + assert re.search(r'Connected to radio', out, re.MULTILINE) + assert re.search(r'Set wifiSsid to foo', out, re.MULTILINE) + assert err == '' + mo.assert_called() + + @pytest.mark.unit @pytest.mark.usefixtures("reset_globals") def test_main_set_with_invalid(capsys): @@ -809,7 +831,7 @@ def test_main_set_with_invalid(capsys): # TODO: write some negative --configure tests @pytest.mark.unit @pytest.mark.usefixtures("reset_globals") -def test_main_configure(capsys): +def test_main_configure_with_snake_case(capsys): """Test --configure with valid file""" sys.argv = ['', '--configure', 'example_config.yaml'] Globals.getInstance().set_args(sys.argv) @@ -833,6 +855,31 @@ def test_main_configure(capsys): mo.assert_called() +@pytest.mark.unit +@pytest.mark.usefixtures("reset_globals") +def test_main_configure_with_camel_case_keys(capsys): + """Test --configure with valid file""" + sys.argv = ['', '--configure', 'exampleConfig.yaml'] + Globals.getInstance().set_args(sys.argv) + + mocked_node = MagicMock(autospec=Node) + + iface = MagicMock(autospec=SerialInterface) + iface.getNode.return_value = mocked_node + + with patch('meshtastic.serial_interface.SerialInterface', return_value=iface) as mo: + main() + out, err = capsys.readouterr() + assert re.search(r'Connected to radio', out, re.MULTILINE) + assert re.search(r'Setting device owner', out, re.MULTILINE) + assert re.search(r'Setting channel url', out, re.MULTILINE) + assert re.search(r'Fixing altitude', out, re.MULTILINE) + assert re.search(r'Fixing latitude', out, re.MULTILINE) + assert re.search(r'Fixing longitude', out, re.MULTILINE) + assert re.search(r'Writing modified preferences', out, re.MULTILINE) + assert err == '' + mo.assert_called() + @pytest.mark.unit @pytest.mark.usefixtures("reset_globals") def test_main_ch_add_valid(capsys): @@ -1289,6 +1336,33 @@ def test_main_get_with_valid_values(capsys): assert err == '' +@pytest.mark.unit +@pytest.mark.usefixtures("reset_globals") +def test_main_get_with_valid_values_camel(capsys, caplog): + """Test --get with valid values (with string, number, boolean)""" + sys.argv = ['', '--get', 'lsSecs', '--get', 'wifiSsid', '--get', 'fixedPosition'] + Globals.getInstance().set_args(sys.argv) + Globals.getInstance().set_camel_case() + + with caplog.at_level(logging.DEBUG): + with patch('meshtastic.serial_interface.SerialInterface') as mo: + + mo().getNode().radioConfig.preferences.wifi_ssid = 'foo' + mo().getNode().radioConfig.preferences.ls_secs = 300 + mo().getNode().radioConfig.preferences.fixed_position = False + + main() + + mo.assert_called() + + out, err = capsys.readouterr() + assert re.search(r'Connected to radio', out, re.MULTILINE) + assert re.search(r'lsSecs: 300', out, re.MULTILINE) + assert re.search(r'wifiSsid: foo', out, re.MULTILINE) + assert re.search(r'fixedPosition: False', out, re.MULTILINE) + assert err == '' + + @pytest.mark.unit @pytest.mark.usefixtures("reset_globals") def test_main_get_with_invalid(capsys): @@ -1492,6 +1566,45 @@ position_flags: 35""" assert err == '' +@pytest.mark.unit +@pytest.mark.usefixtures("reset_globals") +def test_main_export_config_use_camel(capsys): + """Test export_config() function directly""" + Globals.getInstance().set_camel_case() + iface = MagicMock(autospec=SerialInterface) + with patch('meshtastic.serial_interface.SerialInterface', return_value=iface) as mo: + mo.getLongName.return_value = 'foo' + mo.localNode.getURL.return_value = 'bar' + mo.getMyNodeInfo().get.return_value = { 'latitudeI': 1100000000, 'longitudeI': 1200000000, + 'altitude': 100, 'batteryLevel': 34, 'latitude': 110.0, + 'longitude': 120.0} + mo.localNode.radioConfig.preferences = """phone_timeout_secs: 900 +ls_secs: 300 +position_broadcast_smart: true +fixed_position: true +position_flags: 35""" + export_config(mo) + out, err = capsys.readouterr() + + # ensure we do not output this line + assert not re.search(r'Connected to radio', out, re.MULTILINE) + + assert re.search(r'owner: foo', out, re.MULTILINE) + assert re.search(r'channelUrl: bar', out, re.MULTILINE) + assert re.search(r'location:', out, re.MULTILINE) + assert re.search(r'lat: 110.0', out, re.MULTILINE) + assert re.search(r'lon: 120.0', out, re.MULTILINE) + assert re.search(r'alt: 100', out, re.MULTILINE) + assert re.search(r'userPrefs:', out, re.MULTILINE) + assert re.search(r'phoneTimeoutSecs: 900', out, re.MULTILINE) + assert re.search(r'lsSecs: 300', out, re.MULTILINE) + # TODO: should True be capitalized here? + assert re.search(r"positionBroadcastSmart: 'True'", out, re.MULTILINE) + assert re.search(r"fixedPosition: 'True'", out, re.MULTILINE) + assert re.search(r"positionFlags: 35", out, re.MULTILINE) + assert err == '' + + @pytest.mark.unit @pytest.mark.usefixtures("reset_globals") def test_main_export_config_called_from_main(capsys): diff --git a/meshtastic/tests/test_util.py b/meshtastic/tests/test_util.py index 03cf4c9..515fc76 100644 --- a/meshtastic/tests/test_util.py +++ b/meshtastic/tests/test_util.py @@ -10,7 +10,8 @@ from meshtastic.util import (fixme, stripnl, pskToString, our_exit, support_info, genPSK256, fromStr, fromPSK, quoteBooleans, catchAndIgnore, remove_keys_from_dict, Timeout, hexstr, - ipstr, readnet_u16, findPorts, convert_mac_addr) + ipstr, readnet_u16, findPorts, convert_mac_addr, + snake_to_camel, camel_to_snake) @pytest.mark.unit @@ -251,3 +252,22 @@ def test_convert_mac_addr(): assert convert_mac_addr('/c0gFyhb') == 'fd:cd:20:17:28:5b' assert convert_mac_addr('fd:cd:20:17:28:5b') == 'fd:cd:20:17:28:5b' assert convert_mac_addr('') == '' + + +@pytest.mark.unit +def test_snake_to_camel(): + """Test snake_to_camel""" + assert snake_to_camel('') == '' + assert snake_to_camel('foo') == 'foo' + assert snake_to_camel('foo_bar') == 'fooBar' + assert snake_to_camel('fooBar') == 'fooBar' + + +@pytest.mark.unit +def test_camel_to_snake(): + """Test camel_to_snake""" + assert camel_to_snake('') == '' + assert camel_to_snake('foo') == 'foo' + assert camel_to_snake('Foo') == 'foo' + assert camel_to_snake('fooBar') == 'foo_bar' + assert camel_to_snake('fooBarBaz') == 'foo_bar_baz' diff --git a/meshtastic/util.py b/meshtastic/util.py index 8abbea9..a2fa57c 100644 --- a/meshtastic/util.py +++ b/meshtastic/util.py @@ -243,3 +243,17 @@ def convert_mac_addr(val): val_as_bytes = base64.b64decode(val) return hexstr(val_as_bytes) return val + + +def snake_to_camel(a_string): + """convert snake_case to camelCase""" + # split underscore using split + temp = a_string.split('_') + # joining result + result = temp[0] + ''.join(ele.title() for ele in temp[1:]) + return result + + +def camel_to_snake(a_string): + """convert camelCase to snake_case""" + return ''.join(['_'+i.lower() if i.isupper() else i for i in a_string]).lstrip('_')