Smart home · A finite application receipt experiment
Make two synthetic rooms exchange one door alert through a real local broker. Observe a broker acknowledgement with no receiver running, then add a receiver, delay its reply and restart it. The useful result is knowing which part of the journey was observed—and when the result must remain unknown.
Message Queuing Telemetry Transport (MQTT) is a publish/subscribe messaging protocol: a client publishes to a named topic and a broker routes messages to subscribed clients. Quality of Service (QoS) describes a protocol delivery exchange. QoS 1 permits duplicates and uses a publish acknowledgement, PUBACK. That packet acknowledges one MQTT link. The sender’s broker can acknowledge ownership without proving that another application displayed an alert.
An application receipt is a separate message with a meaning we define. Here, DISPLAYED means the receiver’s synchronous console print returned successfully. It does not mean a person saw it, a physical buzzer sounded, a door changed state or a durable record reached storage. The received OPEN or CLOSED label is synthetic test input. No magnetic switch, radio node, alarm or output actuator is implemented.
The intended hardware host is Raspberry Pi 4 Model B running a compatible Python environment. The author actually ran the code on the recorded macOS host using CPython 3.9.6, with a real isolated Mosquitto 2.1.2 broker and Paho MQTT Python 2.1.0. The recipe below recommends CPython 3.11 or newer, which was not the author’s executed runtime. No Pi, general-purpose input/output (GPIO) terminal, local-area network (LAN), persistent service or physical room was tested. On a Pi use its normal regulated 5.1 V, 3 A USB-C supply; leave the 40-pin header unconnected for this experiment. USB means Universal Serial Bus. Do not power a Pi through two supplies at once.
Transmission Control Protocol (TCP) supplies the byte stream under MQTT. Every delivered program connects explicitly to loopback address 127.0.0.1, a connection back into the same computer, on port 18885. The temporary broker configuration binds only that address, allows local anonymous clients and disables persistence. Anonymous operation is appropriate only for this isolated exercise. Do not change it to a wildcard address or use it as an authenticated multiroom installation.

Room A subscribes to tricky105/receipt before publishing to tricky105/alert. Room B subscribes to the alert topic before printing READY. Each connection waits for the connection acknowledgement and subscription acknowledgement. A successful socket connection alone does not establish a subscription. MQTT packet identifiers are managed by Paho; our 128-bit hexadecimal alert ID is a different application field and stays the same when manually repeating one alert.
A fresh randomly generated ID separates a new experiment from an old reply. The central contract is one ID for one immutable intent: repeat an ID only for the identical door/state payload. Receipts bind only the ID, not the state or a payload digest. If you reuse an ID for a changed state, an older in-flight receipt can be misattributed to the new request even when the receiver rejects that conflict. Cache conflict rejection does not provide end-to-end intent correlation. This correlation is not authentication: any local process allowed onto the broker could forge a receipt. There is no encryption or authorization claim. A production design needs a separately reviewed security and storage model; it cannot be obtained by exposing this listener to Wi-Fi.
| Field or event | What this experiment has observed | What it cannot establish |
|---|---|---|
broker_ack=true | Paho reports this QoS 1 publication acknowledged by the local broker | Receiver running, display success or human attention |
application=RECEIPT | Matching non-retained receipt arrived before the sender’s deadline | Durability, exactly-once action, physical alarm or bounded network latency |
application=UNKNOWN | No qualifying receipt before the deadline, or a clock fault | That the receiver did nothing |
The sender uses a monotonic clock, an elapsed-time clock that is not adjusted as a wall clock is. Its timeout is a local observation window of 0.05–5 seconds. At the exact deadline the result becomes UNKNOWN. A late receipt cannot turn that terminal result into success. If scheduling or console delays keep the loop from running promptly, the program can notice the expiry late; this is not a hard real-time alarm. A backwards or non-finite clock reading fails closed.
It is possible to observe an application receipt before the sender has processed its own PUBACK. The two fields are intentionally independent. Conversely, the easiest counterexample is to run no receiver: the broker can acknowledge the publication while the application result times out. A durable queue and a last-will message—a broker-generated notice after an abnormal client disconnect—would not turn the broker’s acknowledgement into proof of a human response.
A process-local cache stores at most 32 alert IDs, their complete validated payloads and whether the print completed. An identical repeated ID waiting for its delay does not add another pending action. Once displayed, an identical repeat generates another receipt without another display. Reusing an ID for a different state is a conflict; the receiver declines to acknowledge it. Once capacity is reached, new IDs are rejected rather than silently evicting evidence.
This is deliberately finite duplicate suppression. Restart the receiver and the cache is gone: replaying the same ID can display it again. A sender restart generates a new ID unless you explicitly supply one. MQTT clean sessions and disabled broker persistence simplify this lab; neither stores our application’s completion ledger. Exactly-once physical action would require a transactional relationship between action and durable record, not just a stronger QoS number.
Both payloads are small JavaScript Object Notation (JSON) objects with exact fields, integer version 1 and 32 lowercase hexadecimal ID characters. Duplicate JSON keys, booleans standing in for the version number, extra fields, invalid encoding and messages over 256 bytes are rejected. Retained messages—broker-stored values replayed on a subscription—are never accepted as a new alert or receipt. Do not use a retained receipt to claim a new end-to-end exchange.
Suppose room B waits 0.30 seconds before printing, while room A permits only 0.05 seconds for a receipt. The intended local timeline is broker acknowledgement, sender timeout, receiver print and eventual receipt. Room A must remain UNKNOWN even though room B later displayed the alert. This constructed timing example is a semantic prediction; actual process scheduling and socket timing vary.

The delay is implemented as a due time checked by the receiver’s main loop; the callback does not sleep or wait for a publication. Blocking inside a callback would prevent the same network loop from making progress. Receiver receipt publications are queued without a blocking acknowledgement wait. If its connection fails or the process exits before delivery, the sender can still time out. Console failure raises before the displayed flag or receipt is created; this error path is exercised separately from a working terminal.
Use an existing Mosquitto 2.1.2 executable and a disposable Python environment with paho-mqtt==2.1.0. Do not install a system service for this lab. The archive contains the complete program, the test suite, broker configuration and blank worksheet; the program below is identical to its download. The Paho dependency is pinned but is not bundled into the experiment archive.
python3 -m venv .venv
. .venv/bin/activate
python -m pip install paho-mqtt==2.1.0
mosquitto -c mosquitto-loopback.conf
# In another terminal, run without a receiver first:
python door_ack_lab.py sender --timeout 0.2
# Then run these in two terminals:
python door_ack_lab.py receiver --duration 5
python door_ack_lab.py sender --timeout 1The first test should normally show broker acknowledgement and UNKNOWN. The two-terminal run can produce RECEIPT. A missing executable, occupied port, refused connection or failed subscription is an error rather than a fabricated successful result. Every program connection is closed in a finally block. There is no automatic reconnect or automatic resend after failure; decide what an unknown outcome means before retrying a real action.
"""Finite loopback MQTT application-receipt experiment. No GPIO/actuation.
CPython 3.11+, paho-mqtt==2.1.0; Mosquitto 2.1.2 is a separate process.
Only 127.0.0.1 is used. Receiver cache is volatile, bounded, not durable.
"""
import argparse
import json
import math
import re
import secrets
import time
ALERT = 'tricky105/alert'
RECEIPT = 'tricky105/receipt'
HEX = re.compile(r'[0-9a-f]{32}\Z')
def finite(value, low, high):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError('NUMBER')
if not math.isfinite(value) or not low <= value <= high:
raise ValueError('RANGE')
return value
def encode(obj):
return json.dumps(obj, sort_keys=True, separators=(',', ':')).encode('ascii')
def decode(raw, kind):
if not isinstance(raw, bytes) or not 1 <= len(raw) <= 256:
raise ValueError('SIZE')
def pairs(items):
d = {}
for k, v in items:
if k in d:
raise ValueError('DUPLICATE_KEY')
d[k] = v
return d
d = json.loads(raw.decode('ascii'), object_pairs_hook=pairs)
keys = {'v', 'id', 'door', 'state'} if kind == 'alert' else {'v', 'id', 'receiver', 'result'}
if not isinstance(d, dict) or set(d) != keys or type(d['v']) is not int or d['v'] != 1:
raise ValueError('SCHEMA')
if not isinstance(d['id'], str) or not HEX.fullmatch(d['id']):
raise ValueError('ID')
if kind == 'alert':
if d['door'] != 'lab-door' or d['state'] not in ('OPEN', 'CLOSED'):
raise ValueError('ALERT')
elif d['receiver'] != 'room-b' or d['result'] != 'DISPLAYED':
raise ValueError('RECEIPT')
return d
class Sender:
def __init__(self, alert_id, started, timeout):
if not isinstance(alert_id, str) or not HEX.fullmatch(alert_id):
raise ValueError('ID')
finite(started, 0, 1e12)
finite(timeout, .05, 5)
self.id, self.start, self.deadline = alert_id, started, started + timeout
self.last = started
self.broker_ack = False
self.application = 'WAITING'
self.fault = None
def clock(self, now):
try:
finite(now, self.last, 1e12)
except ValueError:
self.fault, self.application = 'CLOCK', 'UNKNOWN'
return False
self.last = now
if self.application == 'WAITING' and now >= self.deadline:
self.application = 'UNKNOWN'
return not self.fault
def accept(self, raw, retained, now):
if not self.clock(now) or self.application != 'WAITING' or retained is not False:
return False
try:
d = decode(raw, 'receipt')
except (ValueError, UnicodeError):
return False
if d['id'] != self.id:
return False
self.application = 'RECEIPT'
return True
def snapshot(self, now):
self.clock(now)
return {'id': self.id, 'broker_ack': self.broker_ack,
'application': self.application, 'fault': self.fault}
class Receiver:
def __init__(self, delay=0):
self.delay = finite(delay, 0, 5)
self.cache = {} # ID -> (fingerprint, due, displayed)
self.last = 0
def receive(self, raw, retained, now):
finite(now, self.last, 1e12)
self.last = now
if retained is not False:
return None
try:
d = decode(raw, 'alert')
except (ValueError, UnicodeError):
return None
fingerprint = encode(d)
old = self.cache.get(d['id'])
if old:
if old[0] != fingerprint:
return None # ID collision/conflict: do not acknowledge
return self.receipt(d['id']) if old[2] else None
if len(self.cache) >= 32:
return None # fail closed; no eviction within this process
self.cache[d['id']] = [fingerprint, now + self.delay, False]
return None
@staticmethod
def receipt(alert_id):
return encode({'v': 1, 'id': alert_id, 'receiver': 'room-b', 'result': 'DISPLAYED'})
def due(self, now, display):
finite(now, self.last, 1e12)
self.last = now
replies = []
for alert_id, item in self.cache.items():
if not item[2] and now >= item[1]:
display(item[0].decode('ascii')) # failure => no displayed flag/receipt
item[2] = True
replies.append(self.receipt(alert_id))
return replies
def connect(port, callback, topic):
import paho.mqtt.client as mqtt
if type(port) is not int or not 1024 <= port <= 65535:
raise ValueError('PORT')
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id='t105-' + secrets.token_hex(8),
protocol=mqtt.MQTTv311, clean_session=True, reconnect_on_failure=False)
c.connect_timeout = 2
ready, subscribed = [], []
def on_connect(client, userdata, flags, reason, properties):
if reason.is_failure:
raise RuntimeError('CONNACK')
ready.append(True)
rc, mid = client.subscribe(topic, qos=1)
if rc != mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError('SUBSCRIBE')
def on_subscribe(client, userdata, mid, reasons, properties):
if not reasons or any(r.is_failure for r in reasons):
raise RuntimeError('SUBACK')
subscribed.append(True)
c.on_connect, c.on_subscribe = on_connect, on_subscribe
c.on_message = callback
try:
c.connect('127.0.0.1', port, keepalive=10)
end = time.monotonic() + 3
while not (ready and subscribed):
pump(c)
if time.monotonic() >= end:
raise TimeoutError('CONNECT_OR_SUBACK')
return c
except BaseException:
c.disconnect()
raise
def pump(c):
import paho.mqtt.client as mqtt
if c.loop(timeout=.01) != mqtt.MQTT_ERR_SUCCESS:
raise ConnectionError('LOOP_DISCONNECTED')
def publish(c, topic, payload):
info = c.publish(topic, payload, qos=1, retain=False)
if info.rc != 0:
raise ConnectionError('PUBLISH')
return info
def send(port=18885, timeout=1, state='OPEN', alert_id=None):
finite(timeout, .05, 5)
if state not in ('OPEN', 'CLOSED'):
raise ValueError('STATE')
alert_id = alert_id or secrets.token_hex(16)
holder = []
def callback(c, u, m):
if holder:
holder[0].accept(bytes(m.payload), bool(m.retain), time.monotonic())
c = connect(port, callback, RECEIPT)
try:
s = Sender(alert_id, time.monotonic(), timeout)
holder.append(s)
info = publish(c, ALERT, encode({'v': 1, 'id': alert_id, 'door': 'lab-door', 'state': state}))
while s.application == 'WAITING':
pump(c)
s.broker_ack = info.is_published()
s.clock(time.monotonic())
return s.snapshot(time.monotonic())
finally:
c.disconnect()
def receive(port=18885, duration=5, delay=0, display=None):
finite(duration, .05, 30)
r = Receiver(delay)
def show(s):
print('DISPLAY ' + s, flush=True)
display = display or show
pending = []
def callback(c, u, m):
reply = r.receive(bytes(m.payload), bool(m.retain), time.monotonic())
if reply:
pending.append(reply)
c = connect(port, callback, ALERT)
try:
print('READY room-b', flush=True)
end = time.monotonic() + duration
while time.monotonic() < end:
pump(c)
pending.extend(r.due(time.monotonic(), display))
for payload in pending:
publish(c, RECEIPT, payload) # no blocking wait inside callback
pending.clear()
return len(r.cache)
finally:
c.disconnect()
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('role', choices=['sender', 'receiver'])
p.add_argument('--port', type=int, default=18885)
p.add_argument('--timeout', type=float, default=1)
p.add_argument('--duration', type=float, default=5)
p.add_argument('--delay', type=float, default=0)
p.add_argument('--id', default=None)
p.add_argument('--state', choices=['OPEN', 'CLOSED'], default='OPEN')
a = p.parse_args()
if a.role == 'sender':
print(json.dumps(send(a.port, a.timeout, a.state, a.id)), flush=True)
else:
receive(a.port, a.duration, a.delay)
if __name__ == '__main__':
main()
Download complete program · Loopback broker configuration · Blank observation sheet · Programs and reproducible tests ZIP
--delay 0.3 and send with --timeout 0.05. Leave the receiver running. Record that the sender can say UNKNOWN while the receiver prints later.--state CLOSED only as an intentional contract-violation test; with no older reply in flight the receiver rejects it and the sender should time out. This is not a general safety guarantee: an older in-flight reply with the same ID can satisfy a new sender because receipts contain no state digest. Never reuse an ID for changed intent in actual use.The supplied automated suite uses a randomly selected loopback port and temporary directory, launches an actual broker, runs actual sender/receiver subprocesses and terminates the broker during cleanup. It also tests retained replies, dead peers, duplicate/conflicting messages, delayed replies, cache capacity, malformed data, terminal clock faults and display failures. These are host software observations, not proof of network availability or a Pi deployment.
The author’s 30 host groups passed: 20 finite policy groups and ten real broker/process groups. Package checks are counted separately. No firmware was compiled, no target application was run and no hardware measurement was made. The blank worksheet contains a header only. A successful test run on this host does not establish response time, security or electrical behavior in another installation.
Acknowledgement delay includes broker routing, client polling, application scheduling, console work and the return path. The program does not subtract clocks from different computers; all deadlines are local. A receiver receipt is still only a claim made by that process. The lab is useful because it makes that claim narrow, correlated and testable instead of treating a transport acknowledgement as an end-to-end guarantee.
For background on preserving freshness rather than merely storing values, see the electronics learning course and use Circuitpedia for first-use terms. These links do not imply that this software exercise has been deployed on the live site.
A reports broker_ack true and UNKNOWN at 50 ms. B prints the same ID at 300 ms. Would automatically retrying with a new ID guarantee one display?
No. UNKNOWN records missing qualifying evidence before the deadline; it does not cancel B’s pending work. The new ID is a distinct alert and can cause a second display. Repeating the same ID suppresses duplicates only while the same receiver cache survives. A real action needs an explicit durable idempotency policy and a recovery procedure for unknown outcomes.