Coverage for zenmqtt/mqtt/subscribe.py: 100%
75 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 21:55 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 21:55 +0000
1import struct
2from dataclasses import dataclass
3from typing import Final, Sequence, Tuple, TypedDict, Union, cast
5from zenmqtt.mqtt.packet import BytesReader, FixedHeader, PacketType
6from zenmqtt.mqtt.properties import Properties, pack_properties, parse_properties
7from zenmqtt.mqtt.reason_codes import ReasonCode, to_reason_codes
8from zenmqtt.mqtt.utils import pack_fixed_header, pack_str16
11class SubscriptionProperties(TypedDict, total=False):
12 subscription_identifier: int
13 user_property: Sequence[Tuple[str, str]]
16class SubackProperties(TypedDict, total=False):
17 reason_string: str
18 user_property: Sequence[Tuple[str, str]]
21class UnsubscribeProperties(TypedDict, total=False):
22 user_property: Sequence[Tuple[str, str]]
25@dataclass(frozen=True)
26class SubscribeResult:
27 __slots__ = ("packet_identifier", "properties", "reason_codes")
29 packet_identifier: int
30 properties: SubackProperties
31 reason_codes: Sequence[ReasonCode]
34@dataclass(frozen=True)
35class UnsubscribeResult:
36 __slots__ = ("packet_identifier", "properties", "reason_codes")
38 packet_identifier: int
39 properties: UnsubscribeProperties
40 reason_codes: Sequence[ReasonCode]
43@dataclass(frozen=True, slots=True)
44class Subscription:
45 """
46 Topic filter with MQTT 5 subscription options.
48 :param no_local: don't receive messages published by this client
49 :param retain_as_published: keep the RETAIN flag of forwarded messages
50 :param retain_handling: 0 - send retained messages on subscribe,
51 1 - only if the subscription is new, 2 - don't send them
52 """
54 topic: str
55 qos: int = 0
56 no_local: bool = False
57 retain_as_published: bool = False
58 retain_handling: int = 0
61# (topic, qos) is the short form of Subscription(topic, qos)
62SubscriptionRequest = Union[Subscription, Tuple[str, int]]
64_SHARED_SUBSCRIPTION_PREFIX: Final[str] = "$share/"
66# subscription options byte
67_NO_LOCAL_FLAG: Final[int] = 0x04
68_RETAIN_AS_PUBLISHED_FLAG: Final[int] = 0x08
69_RETAIN_HANDLING_SHIFT: Final[int] = 4
72def pack_subscription_options(subscription: Subscription) -> int:
73 if subscription.qos not in (0, 1, 2):
74 raise ValueError(f"Invalid QoS: {subscription.qos}")
76 if subscription.retain_handling not in (0, 1, 2):
77 raise ValueError(f"Invalid retain handling: {subscription.retain_handling}")
79 if subscription.no_local and subscription.topic.startswith(
80 _SHARED_SUBSCRIPTION_PREFIX
81 ):
82 raise ValueError("No Local can't be used with a shared subscription")
84 options = subscription.qos | subscription.retain_handling << _RETAIN_HANDLING_SHIFT
86 if subscription.no_local:
87 options |= _NO_LOCAL_FLAG
89 if subscription.retain_as_published:
90 options |= _RETAIN_AS_PUBLISHED_FLAG
92 return options
95def to_subscription(request: SubscriptionRequest) -> Subscription:
96 return request if isinstance(request, Subscription) else Subscription(*request)
99def pack_subscribe_packet(
100 packet_identifier: int,
101 topics: Sequence[SubscriptionRequest],
102 properties: SubscriptionProperties,
103) -> bytes:
104 parts = [
105 struct.pack("!H", packet_identifier),
106 pack_properties(cast(Properties, properties)),
107 ]
109 for request in topics:
110 subscription = to_subscription(request)
111 parts += (
112 pack_str16(subscription.topic),
113 struct.pack("!B", pack_subscription_options(subscription)),
114 )
116 body = b"".join(parts)
118 return pack_fixed_header(PacketType.SUBSCRIBE, 0x2, len(body)) + body
121def parse_suback_packet(
122 fixed_header: FixedHeader, reader: BytesReader
123) -> SubscribeResult:
124 packet_identifier, properties, reason_codes = _parse_packet(fixed_header, reader)
126 return SubscribeResult(
127 packet_identifier=packet_identifier,
128 properties=cast(SubackProperties, properties),
129 reason_codes=reason_codes,
130 )
133def pack_unsubscribe_packet(
134 packet_identifier: int, topics: Sequence[str], properties: UnsubscribeProperties
135) -> bytes:
136 body = b"".join(
137 (
138 struct.pack("!H", packet_identifier),
139 pack_properties(cast(Properties, properties)),
140 *map(pack_str16, topics),
141 )
142 )
144 return pack_fixed_header(PacketType.UNSUBSCRIBE, 0x2, len(body)) + body
147def parse_unsuback_packet(
148 fixed_header: FixedHeader, reader: BytesReader
149) -> UnsubscribeResult:
150 packet_identifier, properties, reason_codes = _parse_packet(fixed_header, reader)
152 return UnsubscribeResult(
153 packet_identifier=packet_identifier,
154 properties=cast(UnsubscribeProperties, properties),
155 reason_codes=reason_codes,
156 )
159def _parse_packet(
160 fixed_header: FixedHeader, reader: BytesReader
161) -> Tuple[int, Properties, Sequence[ReasonCode]]:
162 packet_identifier = reader.read_uint16()
163 properties = parse_properties(reader)
164 # one reason code per topic
165 reason_codes = to_reason_codes(reader.read_rest())
167 return packet_identifier, properties, reason_codes