Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.9", "3.10"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-mock pytest-testdox pytest-timeout

- name: Run tests
run: python -m pytest tests/ -v
81 changes: 75 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Azure IoTHub Service SDK
# Azure IoTHub Service SDK

The Azure IoTHub Service SDK for Python provides functionality for communicating with the Azure IoT Hub.

Expand All @@ -8,21 +8,90 @@ The SDK provides the following clients:

* ### IoT Hub Registry Manager

* Provides CRUD operations for device on IoTHub
* Get statistics about the IoTHub service and devices
* CRUD operations for devices and modules on IoT Hub
* Get service and device registry statistics
* Query device twins using a SQL-like language
* Retrieve and update device twins and module twins
* Invoke direct methods on devices and modules
* **Cloud-to-Device (C2D) messaging** over AMQP (requires `uamqp` — see [Installation](#installation) below)
* Bulk create, update, or delete device identities

* ### IoT Hub Configuration Manager

* CRUD operations for IoT Hub configurations

* ### IoT Hub Job Manager

* Schedule and manage jobs for twin updates and direct method invocations
* Import and export device identities in bulk

* ### IoT Hub HTTP Runtime Manager

* Receive and complete/reject/abandon Cloud-to-Device messages from the device feedback queue

* ### Digital Twin Client

* Get and update digital twins
* Invoke commands on digital twin components

## Installation

```python
### Standard installation

```bash
pip install azure-iot-hub
```

This installs all dependencies needed for every feature **except** Cloud-to-Device (C2D) AMQP messaging on ARM macOS / Apple Silicon (see below).

### Cloud-to-Device (C2D) messaging and `uamqp`

The `send_c2d_message` method on `IoTHubRegistryManager` uses the [uamqp](https://pypi.org/project/uamqp/) library, which is a C extension that must be compiled from source.

| Platform | C2D messaging support | Notes |
|---|---|---|
| Windows | Included automatically | `uamqp` is installed as part of `pip install azure-iot-hub` |
| Linux | Included automatically | `uamqp` is installed as part of `pip install azure-iot-hub` |
| macOS (Intel) | Included automatically | `uamqp` is installed as part of `pip install azure-iot-hub` |
| macOS (Apple Silicon / ARM) | **Not installed automatically** | Recent versions of Xcode/clang enforce stricter C type checking that breaks the `uamqp` build. See below. |

Comment on lines +51 to +57
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Markdown table under “C2D messaging and uamqp” uses double leading pipes (||) in the separator and data rows, which breaks table rendering in common Markdown parsers. Use single | to start each row (e.g., |---|---|---| and | Windows | ... | ... |).

Copilot uses AI. Check for mistakes.
#### Apple Silicon macOS: opting in to C2D messaging

If your Xcode and clang version are compatible, you can install `uamqp` explicitly using the `uamqp` extra:

```bash
pip install azure-iot-hub[uamqp]
```

If `uamqp` cannot be built in your environment, all other SDK features (device/module CRUD, twin operations, direct methods, digital twins, jobs, etc.) work without it. Calling `send_c2d_message` without `uamqp` installed raises an `ImportError` with instructions.

#### AMQP transport options

When `uamqp` is available, C2D messaging supports two transport modes, controlled by the `transport_type` parameter:

```python
from uamqp import TransportType

# Default: AMQP over TCP (port 5671)
registry_manager = IoTHubRegistryManager.from_connection_string(connection_string)

# AMQP over WebSocket (port 443) — useful when port 5671 is blocked by a firewall
registry_manager = IoTHubRegistryManager.from_connection_string(
connection_string,
transport_type=TransportType.AmqpOverWebsocket
)
```

## IoTHub Samples

Check out the [samples repository](https://github.com/Azure/azure-iot-hub-python/tree/main/samples) for more detailed samples
Check out the [samples repository](https://github.com/Azure/azure-iot-hub-python/tree/main/samples) for more detailed samples.

## Getting help and finding API docs
Notable C2D samples:

- [`iothub_registry_manager_c2d_sample.py`](https://github.com/Azure/azure-iot-hub-python/tree/main/samples/iothub_registry_manager_c2d_sample.py) — basic C2D messaging
- [`iothub_registry_manager_c2d_amqp_over_websocket_sample.py`](https://github.com/Azure/azure-iot-hub-python/tree/main/samples/iothub_registry_manager_c2d_amqp_over_websocket_sample.py) — C2D over WebSocket

## Getting help and finding API docs

API documentation for this package is available via [Microsoft Docs](https://docs.microsoft.com/python/api/azure-iot-hub/azure.iot.hub?view=azure-python)

Expand Down
14 changes: 12 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,20 @@
],
install_requires=[
"msrest>=0.6.21,<1.0.0",
# NOTE: Python 2.7, 3.5 support dropped >= 1.4.0
"uamqp>=1.2.14,<2.0.0",
"azure-core>=1.10.0,<2.0.0",
"six>=1.12.0",
# uamqp is a C extension that fails to build on ARM macOS (Apple Silicon) with recent
# clang versions due to stricter type checking. It is excluded from automatic installation
# on that platform only; all other platforms (Windows, Linux, Intel Mac) get it by default.
# ARM Mac users who need C2D messaging can opt in: pip install azure-iot-hub[uamqp]
# NOTE: Python 2.7, 3.5 support dropped in uamqp >= 1.4.0
"uamqp>=1.2.14,<2.0.0; platform_machine != 'arm64' or sys_platform != 'darwin'",
],
extras_require={
# Opt-in for ARM macOS users whose environment can build uamqp.
# Also usable on any platform to pin/re-add the dependency explicitly.
"uamqp": ["uamqp>=1.2.14,<2.0.0"],
},
python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4",
packages=find_packages(
where="src",
Expand Down
2 changes: 1 addition & 1 deletion src/azure/iot/hub/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
"""This module defines constants for use across the azure-iot-hub package
"""

VERSION = "2.7.0"
VERSION = "2.8.0"
29 changes: 26 additions & 3 deletions src/azure/iot/hub/iothub_amqp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,19 @@
from uuid import uuid4
import six.moves.urllib as urllib
from azure.core.credentials import AccessToken
import uamqp

try:
import uamqp

HAS_UAMQP = True
except ImportError:
HAS_UAMQP = False

_UAMQP_MISSING_ERROR = (
"uamqp is required for AMQP-based C2D messaging but is not installed. "
"On ARM macOS (Apple Silicon) it is not installed automatically due to build compatibility "
"issues with recent clang versions. Install it separately with: pip install azure-iot-hub[uamqp]"
)

default_sas_expiry = 3600

Expand All @@ -35,6 +46,8 @@ def send_message_to_device(self, device_id, message, app_props):

:raises: Exception if the Send command is not able to send the message
"""
if not HAS_UAMQP:
raise ImportError(_UAMQP_MISSING_ERROR)
msg_content = message
msg_props = uamqp.message.MessageProperties()
msg_props.message_id = str(uuid4())
Expand Down Expand Up @@ -68,7 +81,12 @@ def send_message_to_device(self, device_id, message, app_props):


class IoTHubAmqpClientSharedAccessKeyAuth(IoTHubAmqpClientBase):
def __init__(self, hostname, shared_access_key_name, shared_access_key, transport_type=uamqp.TransportType.Amqp):
def __init__(self, hostname, shared_access_key_name, shared_access_key, transport_type=None):
if not HAS_UAMQP:
raise ImportError(_UAMQP_MISSING_ERROR)
if transport_type is None:
transport_type = uamqp.TransportType.Amqp

def get_token():
expiry = int(time.time() + default_sas_expiry)
sas = base64.b64decode(shared_access_key)
Expand Down Expand Up @@ -100,8 +118,13 @@ def get_token():

class IoTHubAmqpClientTokenAuth(IoTHubAmqpClientBase):
def __init__(
self, hostname, token_credential, token_scope="https://iothubs.azure.net/.default", transport_type=uamqp.TransportType.Amqp
self, hostname, token_credential, token_scope="https://iothubs.azure.net/.default", transport_type=None
):
if not HAS_UAMQP:
raise ImportError(_UAMQP_MISSING_ERROR)
if transport_type is None:
transport_type = uamqp.TransportType.Amqp

def get_token():
result = token_credential.get_token(token_scope)
return AccessToken("Bearer " + result.token, result.expires_on)
Expand Down
49 changes: 35 additions & 14 deletions src/azure/iot/hub/iothub_registry_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,22 @@
AuthenticationMechanism,
DeviceCapabilities,
)
from uamqp import TransportType

try:
from uamqp import TransportType
except ImportError:
from enum import IntEnum

class TransportType(IntEnum):
"""Transport type for AMQP connections.

Mirrors uamqp.TransportType. Only used when uamqp is not installed;
C2D messaging (send_c2d_message) requires uamqp and will raise an
ImportError if called without it.
"""

Amqp = 1
AmqpOverWebsocket = 3


def _ensure_quoted(etag):
Expand Down Expand Up @@ -68,7 +83,7 @@ def __init__(self, connection_string=None, host=None, token_credential=None, tra
Default value: None
:param transport_type: The underlying transport protocol type: Amqp: AMQP over the default TCP transport protocol, it uses port 5671. AmqpOverWebsocket: Amqp over the Web Sockets transport protocol, it uses port 443.
Default value: Amqp
:type transport_type: :class:`uamqp.TransportType`
:type transport_type: :class:`TransportType`

:returns: Instance of the IoTHubRegistryManager object.
:rtype: :class:`azure.iot.hub.IoTHubRegistryManager`
Expand All @@ -79,19 +94,21 @@ def __init__(self, connection_string=None, host=None, token_credential=None, tra
self.protocol = protocol_client(
conn_string_auth, "https://" + conn_string_auth["HostName"]
)
self.amqp_svc_client = iothub_amqp_client.IoTHubAmqpClientSharedAccessKeyAuth(
conn_string_auth["HostName"],
conn_string_auth["SharedAccessKeyName"],
conn_string_auth["SharedAccessKey"],
transport_type,
)
if iothub_amqp_client.HAS_UAMQP:
self.amqp_svc_client = iothub_amqp_client.IoTHubAmqpClientSharedAccessKeyAuth(
conn_string_auth["HostName"],
conn_string_auth["SharedAccessKeyName"],
conn_string_auth["SharedAccessKey"],
transport_type,
)
else:
self.protocol = protocol_client(
AzureIdentityCredentialAdapter(token_credential), "https://" + host
)
self.amqp_svc_client = iothub_amqp_client.IoTHubAmqpClientTokenAuth(
host, token_credential, transport_type=transport_type
)
if iothub_amqp_client.HAS_UAMQP:
self.amqp_svc_client = iothub_amqp_client.IoTHubAmqpClientTokenAuth(
host, token_credential, transport_type=transport_type
)

@classmethod
def from_connection_string(cls, connection_string, transport_type=TransportType.Amqp):
Expand All @@ -105,7 +122,7 @@ def from_connection_string(cls, connection_string, transport_type=TransportType.
with IoTHub.
:param transport_type: The underlying transport protocol type: Amqp: AMQP over the default TCP transport protocol, it uses port 5671. AmqpOverWebsocket: Amqp over the Web Sockets transport protocol, it uses port 443.
Default value: Amqp
:type transport_type: :class:`uamqp.TransportType`
:type transport_type: :class:`TransportType`

:rtype: :class:`azure.iot.hub.IoTHubRegistryManager`
"""
Expand All @@ -124,7 +141,7 @@ def from_token_credential(cls, url, token_credential, transport_type=TransportTy
:type token_credential: :class:`azure.core.TokenCredential`
:param transport_type: The underlying transport protocol type: Amqp: AMQP over the default TCP transport protocol, it uses port 5671. AmqpOverWebsocket: Amqp over the Web Sockets transport protocol, it uses port 443.
Default value: Amqp
:type transport_type: :class:`uamqp.TransportType`
:type transport_type: :class:`TransportType`

:rtype: :class:`azure.iot.hub.IoTHubRegistryManager`
"""
Expand Down Expand Up @@ -926,7 +943,7 @@ def invoke_device_module_method(self, device_id, module_id, direct_method_reques

return self.protocol.modules.invoke_method(device_id, module_id, direct_method_request)

def send_c2d_message(self, device_id, message, properties={}):
def send_c2d_message(self, device_id, message, properties=None):
"""Send a C2D message to a IoTHub Device.

:param str device_id: The name (Id) of the device.
Expand All @@ -936,4 +953,8 @@ def send_c2d_message(self, device_id, message, properties={}):

:raises: Exception if the Send command is not able to send the message
"""
if self.amqp_svc_client is None:
raise ImportError(iothub_amqp_client._UAMQP_MISSING_ERROR)
if properties is None:
properties = {}
self.amqp_svc_client.send_message_to_device(device_id, message, properties)
4 changes: 3 additions & 1 deletion tests/test_iothub_amqp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import base64
import hashlib
import pytest
import uamqp

uamqp = pytest.importorskip("uamqp")

import hmac
from azure.core.credentials import AccessToken
from azure.iot.hub.iothub_amqp_client import (
Expand Down
13 changes: 11 additions & 2 deletions tests/test_iothub_registry_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@

import pytest
from azure.iot.hub.protocol.models import AuthenticationMechanism, DeviceCapabilities
from azure.iot.hub.iothub_registry_manager import IoTHubRegistryManager
from azure.iot.hub.iothub_registry_manager import IoTHubRegistryManager, TransportType
from azure.iot.hub import iothub_amqp_client
from azure.iot.hub.protocol.iot_hub_gateway_service_ap_is import IotHubGatewayServiceAPIs
from uamqp import TransportType

requires_uamqp = pytest.mark.skipif(
not iothub_amqp_client.HAS_UAMQP, reason="uamqp not installed"
)

"""---Constants---"""

Expand Down Expand Up @@ -161,6 +164,7 @@ class TestFromConnectionString:
),
],
)
@requires_uamqp
@pytest.mark.it(
"Creates an instance of IotHubGatewayServiceAPIs and IoTHubAmqpClientSharedAccessKeyAuth with the correct arguments"
)
Expand Down Expand Up @@ -205,6 +209,7 @@ def test_connection_string_auth(self, mocker, connection_string):
),
],
)
@requires_uamqp
@pytest.mark.it(
"Creates an instance of IotHubGatewayServiceAPIs and IoTHubAmqpClientSharedAccessKeyAuth with the correct arguments and using AMQP over Websocket"
)
Expand Down Expand Up @@ -275,6 +280,7 @@ def test_instantiates_with_connection_string_no_shared_access_key(self):

@pytest.mark.describe("IoTHubRegistryManager - .from_token_credential()")
class TestFromTokenCredential:
@requires_uamqp
@pytest.mark.it(
"Creates an instance of IotHubGatewayServiceAPIs and IoTHubAmqpClientTokenAuth with the correct arguments"
)
Expand All @@ -294,6 +300,7 @@ def test_token_credential_auth(self, mocker):
assert amqp_client_init_mock.call_args == mocker.call(
fake_hostname, mock_azure_identity_TokenCredential, transport_type=TransportType.Amqp
)
@requires_uamqp
def test_token_credential_auth_with_amqp_over_websocket(self, mocker):
mock_azure_identity_TokenCredential = mocker.MagicMock()
amqp_client_init_mock = mocker.patch.object(iothub_amqp_client, "IoTHubAmqpClientTokenAuth")
Expand Down Expand Up @@ -1430,6 +1437,7 @@ def test_invoke_device_module_method_payload_none(


@pytest.mark.describe("IoTHubRegistryManager - .send_c2d_message()")
@requires_uamqp
class TestSendC2dMessage(object):
@pytest.mark.it("Test send c2d message")
def test_send_c2d_message(
Expand All @@ -1445,6 +1453,7 @@ def test_send_c2d_message(


@pytest.mark.describe("IoTHubRegistryManager - .send_c2d_message() with properties")
@requires_uamqp
class TestSendC2dMessageWithProperties(object):
@pytest.mark.it("Test send c2d message with properties")
def test_send_c2d_message_with_properties(
Expand Down
Loading