diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 894fdfc..f1fe16c 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -19,4 +19,4 @@ jobs: - name: Lint with flake8 run: | pip install flake8 - flake8 . --count --max-complexity=10 --statistics + flake8 . --count --max-complexity=10 --statistics --exclude protos diff --git a/.github/workflows/publish-to-pypi-test.yml b/.github/workflows/publish-to-pypi-test.yml index 249ddef..648ff75 100644 --- a/.github/workflows/publish-to-pypi-test.yml +++ b/.github/workflows/publish-to-pypi-test.yml @@ -17,30 +17,30 @@ jobs: - name: Fetch gateway-mfr-rs env: - GATEWAY_MFR_RS_RELEASE: v0.3.2 + GATEWAY_MFR_RS_RELEASE: "0.4.1" run: | - wget "https://github.com/helium/gateway-mfr-rs/releases/download/${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf-ecc608.tar.gz" - wget "https://github.com/helium/gateway-mfr-rs/releases/download/${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf-ecc608.checksum" - wget "https://github.com/helium/gateway-mfr-rs/releases/download/${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu-ecc608.tar.gz" - wget "https://github.com/helium/gateway-mfr-rs/releases/download/${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu-ecc608.checksum" - wget "https://github.com/helium/gateway-mfr-rs/releases/download/${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-unknown-linux-gnu-ecc608.tar.gz" - wget "https://github.com/helium/gateway-mfr-rs/releases/download/${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-unknown-linux-gnu-ecc608.checksum" - SHA256_ARM=$( shasum -a 256 "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf-ecc608.tar.gz" | awk '{print $1}') - SHA256_AARCH64=$( shasum -a 256 "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu-ecc608.tar.gz" | awk '{print $1}') - SHA256_X86_64=$( shasum -a 256 "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-unknown-linux-gnu-ecc608.tar.gz" | awk '{print $1}') + wget "https://github.com/helium/gateway-mfr-rs/releases/download/v${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf.tar.gz" + wget "https://github.com/helium/gateway-mfr-rs/releases/download/v${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf.checksum" + wget "https://github.com/helium/gateway-mfr-rs/releases/download/v${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu.tar.gz" + wget "https://github.com/helium/gateway-mfr-rs/releases/download/v${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu.checksum" + wget "https://github.com/helium/gateway-mfr-rs/releases/download/v${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-tpm-debian-gnu.tar.gz" + wget "https://github.com/helium/gateway-mfr-rs/releases/download/v${GATEWAY_MFR_RS_RELEASE}/gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-tpm-debian-gnu.checksum" + SHA256_ARM=$( shasum -a 256 "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf.tar.gz" | awk '{print $1}') + SHA256_AARCH64=$( shasum -a 256 "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu.tar.gz" | awk '{print $1}') + SHA256_X86_64=$( shasum -a 256 "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-tpm-debian-gnu.tar.gz" | awk '{print $1}') echo "Generated checksum ARM: ${SHA256_ARM}" echo "Generated checksum AARCH64: ${SHA256_AARCH64}" echo "Generated checksum X86_64: ${SHA256_X86_64}" # Verify the checksums - if grep -q "${SHA256_ARM}" "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf-ecc608.checksum" && grep -q "${SHA256_AARCH64}" "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu-ecc608.checksum" && grep -q "${SHA256_X86_64}" "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-unknown-linux-gnu-ecc608.checksum"; then + if grep -q "${SHA256_ARM}" "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf.checksum" && grep -q "${SHA256_AARCH64}" "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu.checksum" && grep -q "${SHA256_X86_64}" "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-tpm-debian-gnu.checksum"; then echo "Checksum verified for gateway_mfr. Unpacking tarball..." # Unpack the tarballs - tar -xvf "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf-ecc608.tar.gz" + tar -xvf "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-arm-unknown-linux-gnueabihf.tar.gz" mv gateway_mfr gateway_mfr_arm - tar -xvf "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu-ecc608.tar.gz" + tar -xvf "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-aarch64-unknown-linux-gnu.tar.gz" mv gateway_mfr gateway_mfr_aarch64 - tar -xvf "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-unknown-linux-gnu-ecc608.tar.gz" + tar -xvf "gateway-mfr-${GATEWAY_MFR_RS_RELEASE}-x86_64-tpm-debian-gnu.tar.gz" mv gateway_mfr gateway_mfr_x86_64 exit 0 else @@ -53,13 +53,13 @@ jobs: chmod +x gateway_mfr_arm chmod +x gateway_mfr_aarch64 chmod +x gateway_mfr_x86_64 - + - name: Move gateway_mfr in place run: | mv gateway_mfr_arm hm_pyhelper/gateway_mfr mv gateway_mfr_aarch64 hm_pyhelper/gateway_mfr_aarch64 mv gateway_mfr_x86_64 hm_pyhelper/gateway_mfr_x86_64 - + - name: Install pypa/build run: | python -m pip install build --user @@ -78,4 +78,4 @@ jobs: with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/light-hotspot' diff --git a/README.md b/README.md index f786db5..767bab5 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,10 @@ and formatted the same way as the [smartphone app expects](https://docs.helium.c Usage: ``` +While using miner container, use json rpc client client = MinerClient() +While using gateway-rs container, use grpc client +client = GatewayClient() result = client.create_add_gateway_txn('owner_address', 'payer_address', 'gateway_address') ``` diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..deb728a --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1 @@ +grpcio-tools==1.44.0 diff --git a/hm_pyhelper/constants/diagnostics.py b/hm_pyhelper/constants/diagnostics.py index 015bfb1..534b155 100644 --- a/hm_pyhelper/constants/diagnostics.py +++ b/hm_pyhelper/constants/diagnostics.py @@ -14,7 +14,18 @@ LTE_KEY = 'lte' ONBOARDING_KEY = 'onboarding_key' PF_KEY = 'legacy_pass_fail' +LORA_KEY = 'lora' PUBLIC_KEY = 'public_key' SERIAL_NUMBER_KEY = 'serial_number' VARIANT_KEY = 'VARIANT' WIFI_MAC_ADDRESS_KEY = 'wifi_mac_address' + +# gatewayrs diagnostic keys +VALIDATOR_ADDRESS_KEY = 'validator_address' +VALIDATOR_URI_KEY = 'validator_uri' +VALIDATOR_BLOCK_HEIGHT_KEY = 'validator_height' +VALIDATOR_BLOCK_HEIGHT_SHORT_KEY = 'MH' +VALIDATOR_BLOCK_AGE = 'validator_block_age' +GATEWAY_PUBKEY_KEY = 'gateway_pubkey' +GATEWAY_REGION_KEY = 'gateway_region' +GATEWAY_REGION_SHORT_KEY = 'RE' diff --git a/hm_pyhelper/exceptions.py b/hm_pyhelper/exceptions.py index 266b843..46c3529 100644 --- a/hm_pyhelper/exceptions.py +++ b/hm_pyhelper/exceptions.py @@ -18,6 +18,10 @@ class MinerFailedToFetchMacAddress(Exception): pass +class MinerFailedToFetchEthernetAddress(Exception): + pass + + class UnknownVariantException(Exception): pass diff --git a/hm_pyhelper/gateway_grpc/__init__.py b/hm_pyhelper/gateway_grpc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hm_pyhelper/gateway_grpc/client.py b/hm_pyhelper/gateway_grpc/client.py new file mode 100644 index 0000000..522c95b --- /dev/null +++ b/hm_pyhelper/gateway_grpc/client.py @@ -0,0 +1,193 @@ +import base58 +import grpc +import subprocess +import json +from typing import Union + +from hm_pyhelper.protos import blockchain_txn_add_gateway_v1_pb2, \ + local_pb2_grpc, local_pb2, region_pb2, gateway_staking_mode_pb2 +from hm_pyhelper.gateway_grpc.exceptions import MinerMalformedAddGatewayTxn + +from hm_pyhelper.logger import get_logger + +LOGGER = get_logger(__name__) + + +def decode_pub_key(encoded_key: bytes) -> str: + # Addresses returned by the RPC response are missing a leading + # byte for the version. The version is currently always 0. + # https://github.com/helium/helium-js/blob/8d5cb76e156fb80de6fc80f239b43e3872c7b7d7/packages/crypto/src/Address.ts#L64 + version_byte = b'\x00' + + # Convert binary address to base58 + complete_key = version_byte + encoded_key + decoded_key = base58.b58encode_check(complete_key).decode() + return decoded_key + + +class GatewayClient(object): + ''' + GatewayClient wraps grpc api provided by helium gateway-rs + It provides some convenience methods to support the old api + to limit breaking changes. + Direct interaction with the grpc api can be achieved by + using GatewayClient.stub. + + All methods might return grpc pass through exceptions. + ''' + + def __init__(self, url='helium-miner:4467'): + self._url = url + self._channel = grpc.insecure_channel(url) + self._channel.subscribe(self._connect_state_handler) + self.stub = local_pb2_grpc.apiStub(self._channel) + + def _connect_state_handler(self, state): + if state == grpc.ChannelConnectivity.SHUTDOWN: + LOGGER.error('GRPC Channel shutdown : irrecoverable error') + + def __enter__(self): + return self + + def __exit__(self, _, _2, _3): + self._channel.close() + + def get_region_enum(self) -> int: + ''' + Returns the current configured region of the gateway. + If not asserted or set in settings, defaults to 0 (US915) + ref: https://github.com/helium/proto/blob/master/src/region.proto + ''' + return self.stub.region(local_pb2.region_req()).region + + def get_region(self) -> str: + ''' + Returns the current configured region of the gateway. + If not asserted or set in settings, defaults to 0 (US915) + ''' + region_id = self.get_region_enum() + return region_pb2.region.Name(region_id) + + def sign(self, data: bytes) -> bytes: + ''' + Sign a message with the gateway private key + ''' + return self.stub.sign(local_pb2.sign_req(data=data)).signature + + def ecdh(self, address: bytes) -> bytes: + ''' + Return shared secret using ECDH + ''' + return self.stub.ecdh(local_pb2.ecdh_req(address=address)).secret + + def get_pubkey(self) -> str: + ''' + Returns decoded public key of the gateway + ''' + encoded_key = self.stub.pubkey(local_pb2.pubkey_req()).address + return decode_pub_key(encoded_key) + + def get_summary(self) -> dict: + ''' + Returns a dict with following information + { + "region": str + configured region eg. "US915", + "key": str + gateway/device public key + } + ''' + return { + 'region': self.get_region(), + 'key': self.get_pubkey(), + 'gateway_version': self.get_gateway_version(), + } + + def get_gateway_version(self) -> Union[str, None]: + ''' + Returns the current version of the gateway package installed + ''' + # NOTE:: there is a command line argument to helium-gateway + # but it is not exposed in the rpc, falling back to dpkg + try: + output = subprocess.check_output(['dpkg', '-s', 'helium_gateway']) + for line in output.decode().splitlines(): + if line.strip().startswith('Version'): + # dpkg has version without v but github tags begin with v + return "v" + line.split(':')[1].strip() + return None + except subprocess.CalledProcessError: + return None + + def create_add_gateway_txn(self, owner_address: str, payer_address: str, + staking_mode: gateway_staking_mode_pb2.gateway_staking_mode + = gateway_staking_mode_pb2.gateway_staking_mode.light, + gateway_address: str = "") -> dict: + """ + Invokes the txn_add_gateway RPC endpoint on the gateway and returns + the same payload that the smartphone app traditionally expects. + https://docs.helium.com/mine-hnt/full-hotspots/become-a-maker/hotspot-integration-testing/#generate-an-add-hotspot-transaction + + Parameters: + - owner_address: The address of the account that owns the gateway. + - payer_address: The address of the account that will pay for the + transaction. This will typically be the + maker/Nebra's account. + - staking_mode: The staking mode of the gateway. + ref: + https://github.com/helium/proto/blob/master/src/service/local.proto#L38 + - gateway_address: The address of the miner itself. This is + an optional parameter because the miner + will always return it in the payload during + transaction generation. If the param is + provided, it will only be used as extra + validation. + """ + # NOTE:: this is unimplemented as of alpha23 release of the gateway + response = self.stub.add_gateway(local_pb2.add_gateway_req( + owner=owner_address.encode('utf-8'), + payer=payer_address.encode('utf-8'), + staking_mode=staking_mode + )) + result = json.loads(response.decode()) + if result["address"] != gateway_address: + raise MinerMalformedAddGatewayTxn + return result + + +def get_address_from_add_gateway_txn(add_gateway_txn: + blockchain_txn_add_gateway_v1_pb2, + address_type: str, + expected_address: str = None): + """ + Deserializes specified field in the blockchain_txn_add_gateway_v1_pb2 + protobuf to a base58 Helium address. + + Pararms: + - add_gateway_txn: The blockchain_txn_add_gateway_v1_pb2 to + inspect. + - address_type: 'owner', 'gateway', or 'payer'. + - expected_address (optional): Value we expect to be returned. + + Raises: + MinerMalformedAddGatewayTxn if expected_address supplied and + does not match the return value. + """ + + # Addresses returned by the RPC response are missing a leading + # byte for the version. The version is currently always 0. + # https://github.com/helium/helium-js/blob/8d5cb76e156fb80de6fc80f239b43e3872c7b7d7/packages/crypto/src/Address.ts#L64 + version_byte = b'\x00' + + # Convert binary address to base58 + address_bytes = version_byte + getattr(add_gateway_txn, address_type) + address = str(base58.b58encode_check(address_bytes), 'utf-8') + + # Ensure resulting address matches expectation + is_expected_address_defined = expected_address is not None + if is_expected_address_defined and address != expected_address: + msg = f"Expected {address_type} address to be {expected_address}," + \ + f"but is {address}" + raise MinerMalformedAddGatewayTxn(msg) + + return address diff --git a/hm_pyhelper/gateway_grpc/exceptions.py b/hm_pyhelper/gateway_grpc/exceptions.py new file mode 100644 index 0000000..8ad7b16 --- /dev/null +++ b/hm_pyhelper/gateway_grpc/exceptions.py @@ -0,0 +1,2 @@ +class MinerMalformedAddGatewayTxn(Exception): + pass diff --git a/hm_pyhelper/miner_param.py b/hm_pyhelper/miner_param.py index dbf58da..2d77a9e 100644 --- a/hm_pyhelper/miner_param.py +++ b/hm_pyhelper/miner_param.py @@ -15,8 +15,6 @@ GatewayMFRFileNotFoundException, \ MinerFailedToFetchMacAddress, GatewayMFRExecutionException, GatewayMFRInvalidVersion, \ UnsupportedGatewayMfrVersion -from hm_pyhelper.miner_json_rpc.exceptions import \ - MinerFailedToFetchEthernetAddress from hm_pyhelper.hardware_definitions import get_variant_attribute, \ UnknownVariantException, UnknownVariantAttributeException @@ -37,10 +35,8 @@ def run_gateway_mfr(sub_command: str, slot: int = False) -> dict: capture_output=True, check=True ) - LOGGER.info( - 'gateway_mfr response stdout: %s' % run_gateway_mfr_result.stdout) - LOGGER.info( - 'gateway_mfr response stderr: %s' % run_gateway_mfr_result.stderr) + LOGGER.info(f"gateway_mfr response stdout: {run_gateway_mfr_result.stdout}") + LOGGER.info(f"gateway_mfr response stderr: {run_gateway_mfr_result.stderr}") except subprocess.CalledProcessError as e: err_str = "gateway_mfr exited with a non-zero status" LOGGER.exception(err_str) @@ -223,7 +219,7 @@ def provision_key(slot: int, force: bool = False): try: gateway_mfr_result = run_gateway_mfr("provision", slot=slot) - LOGGER.info("[ECC Provisioning] %s", gateway_mfr_result) + LOGGER.info(f"[ECC Provisioning] {gateway_mfr_result}") provisioning_successful = True response = gateway_mfr_result @@ -233,9 +229,9 @@ def provision_key(slot: int, force: bool = False): response = str(exp) except Exception as exp: - LOGGER.error("[ECC Provisioning] Error during provisioning. %s" % str(exp)) - provisioning_successful = False response = str(exp) + LOGGER.error(f"[ECC Provisioning] Error during provisioning. {response}") + provisioning_successful = False # Try key generation. if provisioning_successful is False and force is True: @@ -245,8 +241,8 @@ def provision_key(slot: int, force: bool = False): response = gateway_mfr_result except Exception as exp: - LOGGER.error("[ECC Provisioning] key --generate failed: %s" % str(exp)) response = str(exp) + LOGGER.error(f"[ECC Provisioning] key --generate failed: {response}") return provisioning_successful, response @@ -269,7 +265,7 @@ def did_gateway_mfr_test_result_include_miner_key_pass( def get_ethernet_addresses(diagnostics): - # Get ethernet MAC and WIFI address + # Get ethernet and wlan MAC address # The order of the values in the lists is important! # It determines which value will be available for which key @@ -281,13 +277,10 @@ def get_ethernet_addresses(diagnostics): for (path, key) in zip(path_to_files, keys): try: diagnostics[key] = get_mac_address(path) - except MinerFailedToFetchMacAddress as e: - diagnostics[key] = False - LOGGER.error(e) except Exception as e: diagnostics[key] = False LOGGER.error(e) - raise MinerFailedToFetchEthernetAddress(str(e)) + raise MinerFailedToFetchMacAddress(str(e)) def get_mac_address(path): @@ -305,20 +298,18 @@ def get_mac_address(path): The path must be a string value") try: file = open(path) - except MinerFailedToFetchMacAddress as e: - LOGGER.exception(str(e)) except FileNotFoundError as e: # logging as warning because some people remove wifi from their outdoor units. # We can't do anything about these errors even if they were failing wifi units. LOGGER.warning("Failed to find Miner" - "Mac Address file at path %s" % path) + f"Mac Address file at path {path}") raise MinerFailedToFetchMacAddress("Failed to find file" "containing miner mac address. " "Exception: %s" % str(e)) \ .with_traceback(e.__traceback__) except PermissionError as e: LOGGER.exception("Permissions invalid for Miner" - "Mac Address file at path %s" % path) + f"Mac Address file at path {path}") raise MinerFailedToFetchMacAddress("Failed to fetch" "miner mac address. " "Invalid permissions to access " @@ -349,16 +340,16 @@ def retry_get_region(region_override, region_filepath): return region_override LOGGER.debug( - "No region override set (value = %s), will retrieve from miner." % region_override) # noqa: E501 + f"No region override set (value = {region_override}), will retrieve from miner.") # noqa: E501 with open(region_filepath) as region_file: region = region_file.read().rstrip('\n') - LOGGER.debug("Region %s parsed from %s " % (region, region_filepath)) + LOGGER.debug(f"Region {region} parsed from {region_filepath}") is_region_valid = len(region) > 3 if is_region_valid: return region - raise MalformedRegionException("Region %s is invalid" % region) + raise MalformedRegionException(f"Region {region} is invalid") @retry(SPIUnavailableException, delay=SPI_UNAVAILABLE_SLEEP_SECONDS, @@ -367,11 +358,11 @@ def await_spi_available(spi_bus): """ Check that the SPI bus path exists, assuming it is in /dev/{spi_bus} """ - if os.path.exists('/dev/{}'.format(spi_bus)): - LOGGER.debug("SPI bus %s Configured Correctly" % spi_bus) + if os.path.exists(f"/dev/{spi_bus}"): + LOGGER.debug(f"SPI bus {spi_bus} Configured Correctly") return True else: - raise SPIUnavailableException("SPI bus %s not found!" % spi_bus) + raise SPIUnavailableException(f"SPI bus {spi_bus} not found!") def config_search_param(command, param): diff --git a/hm_pyhelper/protos/gateway_staking_mode_pb2.py b/hm_pyhelper/protos/gateway_staking_mode_pb2.py new file mode 100644 index 0000000..d151bb3 --- /dev/null +++ b/hm_pyhelper/protos/gateway_staking_mode_pb2.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: gateway_staking_mode.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1agateway_staking_mode.proto\x12\x06helium*9\n\x14gateway_staking_mode\x12\x0c\n\x08\x64\x61taonly\x10\x00\x12\x08\n\x04\x66ull\x10\x01\x12\t\n\x05light\x10\x02\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'gateway_staking_mode_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _GATEWAY_STAKING_MODE._serialized_start=38 + _GATEWAY_STAKING_MODE._serialized_end=95 +# @@protoc_insertion_point(module_scope) diff --git a/hm_pyhelper/protos/local_pb2.py b/hm_pyhelper/protos/local_pb2.py new file mode 100644 index 0000000..c98dd39 --- /dev/null +++ b/hm_pyhelper/protos/local_pb2.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: local.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import hm_pyhelper.protos.gateway_staking_mode_pb2 as gateway__staking__mode__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0blocal.proto\x12\x0chelium.local\x1a\x1agateway_staking_mode.proto\"9\n\npubkey_res\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x1a\n\x12onboarding_address\x18\x02 \x01(\x0c\"\x0c\n\npubkey_req\")\n\tkeyed_uri\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x0b\n\x03uri\x18\x02 \x01(\t\"\x0c\n\nregion_req\"\x1c\n\nregion_res\x12\x0e\n\x06region\x18\x01 \x01(\x05\"\x0c\n\nrouter_req\",\n\nrouter_res\x12\x0b\n\x03uri\x18\x01 \x01(\t\x12\x11\n\tconnected\x18\x02 \x01(\x08\"c\n\x0f\x61\x64\x64_gateway_req\x12\r\n\x05owner\x18\x01 \x01(\x0c\x12\r\n\x05payer\x18\x02 \x01(\x0c\x12\x32\n\x0cstaking_mode\x18\x03 \x01(\x0e\x32\x1c.helium.gateway_staking_mode\"*\n\x0f\x61\x64\x64_gateway_res\x12\x17\n\x0f\x61\x64\x64_gateway_txn\x18\x01 \x01(\x0c\x32\x8c\x02\n\x03\x61pi\x12<\n\x06pubkey\x12\x18.helium.local.pubkey_req\x1a\x18.helium.local.pubkey_res\x12<\n\x06region\x12\x18.helium.local.region_req\x1a\x18.helium.local.region_res\x12<\n\x06router\x12\x18.helium.local.router_req\x1a\x18.helium.local.router_res\x12K\n\x0b\x61\x64\x64_gateway\x12\x1d.helium.local.add_gateway_req\x1a\x1d.helium.local.add_gateway_resb\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'local_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _PUBKEY_RES._serialized_start=57 + _PUBKEY_RES._serialized_end=114 + _PUBKEY_REQ._serialized_start=116 + _PUBKEY_REQ._serialized_end=128 + _KEYED_URI._serialized_start=130 + _KEYED_URI._serialized_end=171 + _REGION_REQ._serialized_start=173 + _REGION_REQ._serialized_end=185 + _REGION_RES._serialized_start=187 + _REGION_RES._serialized_end=215 + _ROUTER_REQ._serialized_start=217 + _ROUTER_REQ._serialized_end=229 + _ROUTER_RES._serialized_start=231 + _ROUTER_RES._serialized_end=275 + _ADD_GATEWAY_REQ._serialized_start=277 + _ADD_GATEWAY_REQ._serialized_end=376 + _ADD_GATEWAY_RES._serialized_start=378 + _ADD_GATEWAY_RES._serialized_end=420 + _API._serialized_start=423 + _API._serialized_end=691 +# @@protoc_insertion_point(module_scope) diff --git a/hm_pyhelper/protos/local_pb2_grpc.py b/hm_pyhelper/protos/local_pb2_grpc.py new file mode 100644 index 0000000..34b79f3 --- /dev/null +++ b/hm_pyhelper/protos/local_pb2_grpc.py @@ -0,0 +1,165 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +import hm_pyhelper.protos.local_pb2 as local__pb2 + + +class apiStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.pubkey = channel.unary_unary( + '/helium.local.api/pubkey', + request_serializer=local__pb2.pubkey_req.SerializeToString, + response_deserializer=local__pb2.pubkey_res.FromString, + ) + self.region = channel.unary_unary( + '/helium.local.api/region', + request_serializer=local__pb2.region_req.SerializeToString, + response_deserializer=local__pb2.region_res.FromString, + ) + self.router = channel.unary_unary( + '/helium.local.api/router', + request_serializer=local__pb2.router_req.SerializeToString, + response_deserializer=local__pb2.router_res.FromString, + ) + self.add_gateway = channel.unary_unary( + '/helium.local.api/add_gateway', + request_serializer=local__pb2.add_gateway_req.SerializeToString, + response_deserializer=local__pb2.add_gateway_res.FromString, + ) + + +class apiServicer(object): + """Missing associated documentation comment in .proto file.""" + + def pubkey(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def region(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def router(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def add_gateway(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_apiServicer_to_server(servicer, server): + rpc_method_handlers = { + 'pubkey': grpc.unary_unary_rpc_method_handler( + servicer.pubkey, + request_deserializer=local__pb2.pubkey_req.FromString, + response_serializer=local__pb2.pubkey_res.SerializeToString, + ), + 'region': grpc.unary_unary_rpc_method_handler( + servicer.region, + request_deserializer=local__pb2.region_req.FromString, + response_serializer=local__pb2.region_res.SerializeToString, + ), + 'router': grpc.unary_unary_rpc_method_handler( + servicer.router, + request_deserializer=local__pb2.router_req.FromString, + response_serializer=local__pb2.router_res.SerializeToString, + ), + 'add_gateway': grpc.unary_unary_rpc_method_handler( + servicer.add_gateway, + request_deserializer=local__pb2.add_gateway_req.FromString, + response_serializer=local__pb2.add_gateway_res.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'helium.local.api', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class api(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def pubkey(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/helium.local.api/pubkey', + local__pb2.pubkey_req.SerializeToString, + local__pb2.pubkey_res.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def region(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/helium.local.api/region', + local__pb2.region_req.SerializeToString, + local__pb2.region_res.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def router(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/helium.local.api/router', + local__pb2.router_req.SerializeToString, + local__pb2.router_res.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def add_gateway(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/helium.local.api/add_gateway', + local__pb2.add_gateway_req.SerializeToString, + local__pb2.add_gateway_res.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/hm_pyhelper/protos/region_pb2.py b/hm_pyhelper/protos/region_pb2.py new file mode 100644 index 0000000..0635563 --- /dev/null +++ b/hm_pyhelper/protos/region_pb2.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: region.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cregion.proto\x12\x06helium*\xf1\x02\n\x06region\x12\t\n\x05US915\x10\x00\x12\t\n\x05\x45U868\x10\x01\x12\t\n\x05\x45U433\x10\x02\x12\t\n\x05\x43N470\x10\x03\x12\r\n\x05\x43N779\x10\x04\x1a\x02\x08\x01\x12\t\n\x05\x41U915\x10\x05\x12\x0b\n\x07\x41S923_1\x10\x06\x12\t\n\x05KR920\x10\x07\x12\t\n\x05IN865\x10\x08\x12\x0b\n\x07\x41S923_2\x10\t\x12\x0b\n\x07\x41S923_3\x10\n\x12\x0b\n\x07\x41S923_4\x10\x0b\x12\x0c\n\x08\x41S923_1B\x10\x0c\x12\x0c\n\x08\x43\x44\x39\x30\x30_1A\x10\r\x12\t\n\x05RU864\x10\x0e\x12\x0b\n\x07\x45U868_A\x10\x0f\x12\x0b\n\x07\x45U868_B\x10\x10\x12\x0b\n\x07\x45U868_C\x10\x11\x12\x0b\n\x07\x45U868_D\x10\x12\x12\x0b\n\x07\x45U868_E\x10\x13\x12\x0b\n\x07\x45U868_F\x10\x14\x12\r\n\tAU915_SB1\x10\x15\x12\r\n\tAU915_SB2\x10\x16\x12\x0c\n\x08\x41S923_1A\x10\x17\x12\x0c\n\x08\x41S923_1C\x10\x18\x12\x0c\n\x08\x41S923_1D\x10\x19\x12\x0c\n\x08\x41S923_1E\x10\x1a\x12\x0c\n\x08\x41S923_1F\x10\x1b\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'region_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _REGION.values_by_name["CN779"]._options = None + _REGION.values_by_name["CN779"]._serialized_options = b'\010\001' + _REGION._serialized_start=25 + _REGION._serialized_end=394 +# @@protoc_insertion_point(module_scope) diff --git a/hm_pyhelper/tests/test_diagnostic_report.py b/hm_pyhelper/tests/test_diagnostic_report.py index 68ad315..5b99ae1 100644 --- a/hm_pyhelper/tests/test_diagnostic_report.py +++ b/hm_pyhelper/tests/test_diagnostic_report.py @@ -1,5 +1,6 @@ import unittest import json +from hm_pyhelper.constants.diagnostics import ERRORS_KEY from hm_pyhelper.diagnostics import Diagnostic, DiagnosticsReport from hm_pyhelper.diagnostics.diagnostics_report import \ @@ -78,7 +79,7 @@ def test_passed_false_on_errors(self): response = { 'diagnostics_passed': False, - 'errors': ['ECC', 'BN', 'OK', 'PK', 'PF'], + ERRORS_KEY: ['ECC', 'BN', 'OK', 'PK', 'PF'], 'serial_number': '0000000021aabbcc', 'ECC': 'gateway_mfr test finished with error', 'E0': 'F0:4C:D5:58:E0:E1', diff --git a/hm_pyhelper/tests/test_gateway_grpc.py b/hm_pyhelper/tests/test_gateway_grpc.py new file mode 100644 index 0000000..d372bda --- /dev/null +++ b/hm_pyhelper/tests/test_gateway_grpc.py @@ -0,0 +1,99 @@ +import unittest +from unittest.mock import patch +import grpc +from concurrent import futures +from hm_pyhelper.gateway_grpc.client import GatewayClient + +from hm_pyhelper.protos import local_pb2 +from hm_pyhelper.protos import local_pb2_grpc + + +class TestData: + server_port = 4468 + validator_address_decoded = "11yJXQPG9deHqvw2ac6VWtNP7gZj8X3t3Qb3Gqm9j729p4AsdaA" + pubkey_encoded = b"\x01\xc3\x06\x7f\xb9\x19}\xd1n2\xe2M\xeb\xb5\x11\x7f" \ + b"\xbc\x12\xebT\xb9\x84R\xc7\xca\xf8o\xdddx\xea~\xab" + pubkey_decoded = "14RdqcZC2rbdTBwNaTsj5EVWYaM7BKGJ44ycq6wWJy9Hg7RKCii" + region_enum = 0 + region_name = "US915" + dpkg_output = b"""Package: helium_gateway\n + Status: install ok installed\n + Priority: optional\n + Section: utility\n + Installed-Size: 3729\n + Maintainer: Marc Nijdam \n + Architecture: amd64\n + Version: 1.0.0\n + Depends: curl\n + Conffiles:\n + /etc/helium_gateway/settings.toml 4d6fb434f97a50066b8163a371d5c208\n + Description: Helium Gateway for LoRa packet forwarders\n + The Helium Gateway to attach your LoRa gateway to the Helium Blockchain.\n""" + expected_summary = { + 'region': region_name, + 'key': pubkey_decoded, + 'gateway_version': "v1.0.0" + } + + +class MockServicer(local_pb2_grpc.apiServicer): + def height(self, request, context): + return TestData.height_res + + def region(self, request, context): + return local_pb2.region_res(region=0) + + def pubkey(self, request, context): + return local_pb2.pubkey_res(address=TestData.pubkey_encoded) + + def config(self, request, context): + result = local_pb2.config_res() + for key in request.keys: + if key in TestData.chain_vars.keys(): + result.values.append(TestData.chain_vars.get(key)) + else: + result.values.append(local_pb2.config_value(name=key)) + return result + + +class TestGatewayGRPCClient(unittest.TestCase): + + # we can start the real service hear by installing dpkg. But AFAIK + # our testing methods, real service exposes us to random failures + def setUp(self): + self.mock_server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + local_pb2_grpc.add_apiServicer_to_server(MockServicer(), self.mock_server) + self.mock_server.add_insecure_port(f'[::]:{TestData.server_port}') + self.mock_server.start() + + def tearDown(self): + self.mock_server.stop(None) + + def test_get_pubkey(self): + with GatewayClient(f'localhost:{TestData.server_port}') as client: + self.assertEqual(client.get_pubkey(), TestData.pubkey_decoded) + + def test_get_region(self): + with GatewayClient(f'localhost:{TestData.server_port}') as client: + self.assertEqual(client.get_region_enum(), TestData.region_enum) + self.assertEqual(client.get_region(), TestData.region_name) + + def test_get_summary(self): + with GatewayClient(f'localhost:{TestData.server_port}') as client: + # summary when helium_gateway is not installed + test_summary_copy = TestData.expected_summary.copy() + test_summary_copy['gateway_version'] = None + self.assertIn(client.get_summary(), + [TestData.expected_summary, test_summary_copy]) + + @patch('subprocess.check_output', return_value=TestData.dpkg_output) + def test_get_gateway_version(self, mock_check_output): + mock_check_output.return_value = TestData.dpkg_output + with GatewayClient(f'localhost:{TestData.server_port}') as client: + self.assertIn(client.get_gateway_version(), + [TestData.expected_summary['gateway_version'], None]) + + def test_connection_failure(self): + with self.assertRaises(grpc.RpcError): + with GatewayClient('localhost:1234') as client: + client.get_pubkey() diff --git a/hm_pyhelper/tests/test_miner_json_rpc.py b/hm_pyhelper/tests/test_miner_json_rpc.py index b740e48..783b5bd 100644 --- a/hm_pyhelper/tests/test_miner_json_rpc.py +++ b/hm_pyhelper/tests/test_miner_json_rpc.py @@ -15,7 +15,6 @@ def response_result(data, status): url = "https://fake_url" responses.add(responses.POST, url, json=data, status=status) resp = requests.post(url) - print(resp.json()) return resp diff --git a/protos/README.md b/protos/README.md index fd14e46..4479460 100644 --- a/protos/README.md +++ b/protos/README.md @@ -16,3 +16,5 @@ DST_DIR=/PATH/TO/hm-pyhelper/hm_pyhelper/protos protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/blockchain_txn.proto protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/blockchain_txn_add_gateway_v1.proto ``` + +For frequently changing proto files, we use [this] (https://github.com/NebraLtd/hm-pyhelper/blob/master/protos/update_protos.sh) script to download and generate the python code. \ No newline at end of file diff --git a/protos/gateway_staking_mode.proto b/protos/gateway_staking_mode.proto new file mode 100644 index 0000000..795e4dc --- /dev/null +++ b/protos/gateway_staking_mode.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package helium; + +enum gateway_staking_mode { + dataonly = 0; + full = 1; + light = 2; +} diff --git a/protos/local.proto b/protos/local.proto new file mode 100644 index 0000000..2c7dfc3 --- /dev/null +++ b/protos/local.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +package helium.local; + +import "gateway_staking_mode.proto"; + +message pubkey_res { + bytes address = 1; + bytes onboarding_address = 2; +} +message pubkey_req {} + +message keyed_uri { + bytes address = 1; + string uri = 2; +} + +message region_req {} +message region_res { int32 region = 1; } + +message router_req {} +message router_res { + string uri = 1; + bool connected = 2; +} + +message add_gateway_req { + bytes owner = 1; + bytes payer = 2; + gateway_staking_mode staking_mode = 3; +} + +message add_gateway_res { bytes add_gateway_txn = 1; } + +service api { + rpc pubkey(pubkey_req) returns (pubkey_res); + rpc region(region_req) returns (region_res); + rpc router(router_req) returns (router_res); + rpc add_gateway(add_gateway_req) returns (add_gateway_res); +} diff --git a/protos/region.proto b/protos/region.proto new file mode 100644 index 0000000..facb13e --- /dev/null +++ b/protos/region.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package helium; + +enum region { + US915 = 0; + EU868 = 1; + EU433 = 2; + CN470 = 3; + CN779 = 4 [ deprecated = true ]; + AU915 = 5; + AS923_1 = 6; + KR920 = 7; + IN865 = 8; + AS923_2 = 9; + AS923_3 = 10; + AS923_4 = 11; + AS923_1B = 12; + CD900_1A = 13; + RU864 = 14; + EU868_A = 15; + EU868_B = 16; + EU868_C = 17; + EU868_D = 18; + EU868_E = 19; + EU868_F = 20; + AU915_SB1 = 21; + AU915_SB2 = 22; + AS923_1A = 23; + AS923_1C = 24; + AS923_1D = 25; + AS923_1E = 26; + AS923_1F = 27; +} diff --git a/protos/update_protos.sh b/protos/update_protos.sh new file mode 100755 index 0000000..08305a1 --- /dev/null +++ b/protos/update_protos.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Making sure it is installed and up to date +pip install grpcio-tools + +SRC_DIR=. +DST_DIR=../hm_pyhelper/protos + +function update_proto() { + echo "Updating $2" + + # replace old if succcessful + if [[ $(wget "$1" -O "$2.1") -eq 0 ]]; then + echo -e "Overwriting $2..\n" + mv "$2.1" "$2" + fi +} + +update_proto https://raw.githubusercontent.com/helium/proto/master/src/service/local.proto local.proto +update_proto https://raw.githubusercontent.com/helium/proto/master/src/region.proto region.proto +update_proto https://raw.githubusercontent.com/helium/proto/master/src/gateway_staking_mode.proto gateway_staking_mode.proto + +python -m grpc_tools.protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/gateway_staking_mode.proto +python -m grpc_tools.protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/region.proto +python -m grpc_tools.protoc -I=$SRC_DIR --python_out=$DST_DIR --grpc_python_out=$DST_DIR $SRC_DIR/local.proto + +sed -i -e 's/import *local_pb2/import hm_pyhelper.protos.local_pb2/' $DST_DIR/local_pb2_grpc.py +sed -i -e 's/import *gateway_staking_mode_pb2/import hm_pyhelper.protos.gateway_staking_mode_pb2/' $DST_DIR/local_pb2.py diff --git a/requirements.txt b/requirements.txt index 7b6e919..4e11c17 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -requests==2.28.1 +requests>=2.28.1 retry==0.9.2 base58==2.1.1 protobuf==4.21.12 -packaging>=22.0 \ No newline at end of file +grpcio==1.53.0 +packaging>=22.0 diff --git a/setup.py b/setup.py index f12d917..c606bd8 100644 --- a/setup.py +++ b/setup.py @@ -1,22 +1,25 @@ from setuptools import setup, find_packages -from os.path import join, dirname +import os + +# allow setup.py to be run from any path +here = os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)) +os.chdir(here) + +requires = [ + line.strip() + for line in open(os.path.join(here, "requirements.txt"), "r").readlines() +] setup( name='hm_pyhelper', - version='0.13.59', + version='0.14.0', author="Nebra Ltd", author_email="support@nebra.com", description="Helium Python Helper", - long_description=open(join(dirname(__file__), 'README.md')).read(), + long_description=open(os.path.join(os.path.dirname(__file__), 'README.md')).read(), long_description_content_type="text/markdown", url="https://github.com/NebraLtd/hm-pyhelper", - install_requires=[ - 'requests>=2.28.1', - 'retry==0.9.2', - 'base58==2.1.1', - 'protobuf==4.21.12', - 'packaging>=22.0' - ], + install_requires=requires, project_urls={ "Bug Tracker": "https://github.com/NebraLtd/hm-pyhelper/issues", },