RFID reading and authorization
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 12s

This commit is contained in:
2026-08-19 15:51:13 +02:00
parent db173af1c9
commit d7833100a5
6 changed files with 379 additions and 132 deletions
+55
View File
@@ -6,6 +6,7 @@ import base64
import asyncio import asyncio
import fcntl import fcntl
import atexit import atexit
import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from flask import Flask, Response, request, jsonify, current_app, abort, g from flask import Flask, Response, request, jsonify, current_app, abort, g
from pymongo import MongoClient, ASCENDING, DESCENDING from pymongo import MongoClient, ASCENDING, DESCENDING
@@ -45,6 +46,7 @@ alert_collection = db["alert_data"]
webex_tokens_collection = db["webex_tokens"] webex_tokens_collection = db["webex_tokens"]
device_ip_collection = db["device_ips"] device_ip_collection = db["device_ips"]
cve_audit_results = db["cve_audit_results"] cve_audit_results = db["cve_audit_results"]
rfid_cards_collection = db["rfid_cards"]
# Initialize Database Indexes # Initialize Database Indexes
def init_db_indexes(): def init_db_indexes():
@@ -490,6 +492,52 @@ def run_cve_audit():
"timestamp": datetime.now(timezone.utc).isoformat() "timestamp": datetime.now(timezone.utc).isoformat()
}), status_code }), status_code
@app.route("/rfid-card", methods=["GET"])
def get_rfid_cards():
"""OData compliant endpoint for technician RFID card validation."""
filter_str = request.args.get("$filter", "")
card_id = request.args.get("card_id")
mongo_query = {}
# Extract card_id from OData $filter string if not supplied directly
if not card_id and filter_str:
match = re.search(r"(?:card_id|cardId)\s+eq\s+['\"]([^'\"]+)['\"]", filter_str, re.IGNORECASE)
if match:
card_id = match.group(1)
else:
# Generic fallback to extract quoted strings
generic_match = re.search(r"['\"]([a-zA-Z0-9]+)['\"]", filter_str)
if generic_match:
card_id = generic_match.group(1)
if card_id:
mongo_query["card_id"] = card_id
# Filter for active/valid cards only
mongo_query["valid"] = True
try:
cards = list(rfid_cards_collection.find(mongo_query))
result_value = [
{
"id": str(card["_id"]),
"card_id": card.get("card_id"),
"valid": card.get("valid", False)
}
for card in cards
]
return jsonify({
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#RfidCards",
"value": result_value
}), 200
except Exception as e:
current_app.logger.exception("Error querying rfid_cards collection")
return jsonify({"@odata.error": {"code": "500", "message": "Database query error"}}), 500
# --------------------------------------------------------- # ---------------------------------------------------------
# Debug Endpoints (Protected) # Debug Endpoints (Protected)
# --------------------------------------------------------- # ---------------------------------------------------------
@@ -570,11 +618,18 @@ def odata_metadata():
<Property Name="webex_status" Type="Edm.String"/> <Property Name="webex_status" Type="Edm.String"/>
<Property Name="sms_sent" Type="Edm.Boolean"/> <Property Name="sms_sent" Type="Edm.Boolean"/>
</EntityType> </EntityType>
<EntityType Name="RfidCard">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="card_id" Type="Edm.String" Nullable="false"/>
<Property Name="valid" Type="Edm.Boolean" Nullable="false"/>
</EntityType>
<EntityContainer Name="Container"> <EntityContainer Name="Container">
<EntitySet Name="Telemetry" EntityType="MicrowaveNetwork.Telemetry"/> <EntitySet Name="Telemetry" EntityType="MicrowaveNetwork.Telemetry"/>
<EntitySet Name="CookingParams" EntityType="MicrowaveNetwork.CookingParams"/> <EntitySet Name="CookingParams" EntityType="MicrowaveNetwork.CookingParams"/>
<EntitySet Name="CveAudit" EntityType="MicrowaveNetwork.CveAudit"/> <EntitySet Name="CveAudit" EntityType="MicrowaveNetwork.CveAudit"/>
<EntitySet Name="Alerts" EntityType="MicrowaveNetwork.Alert"/> <EntitySet Name="Alerts" EntityType="MicrowaveNetwork.Alert"/>
<EntitySet Name="RfidCards" EntityType="MicrowaveNetwork.RfidCard"/>
</EntityContainer> </EntityContainer>
</Schema> </Schema>
</edmx:DataServices> </edmx:DataServices>
+8 -1
View File
@@ -2,4 +2,11 @@
# IP Adresses # IP Adresses
- AP (dynamic) : `10.110.0.190` - AP (dynamic) : `10.110.0.190`
- Generated Wifi : `192.168.50.1` - Generated Wifi : `192.168.50.1`
# RFID
## Cards
- 2700423026
- 270042302B
+216 -129
View File
@@ -5,13 +5,12 @@ import asyncio
import requests import requests
from shared.microwave_state import MicrowaveState, MicrowaveStateFields from shared.microwave_state import MicrowaveState, MicrowaveStateFields
from orchestrateur.sensors import gps
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
from shared.logging import log from shared.logging import log
from shared.cookingState import CookingStates from shared.cookingState import CookingStates
from shared.lora_device import LoraCommands from shared.lora_device import LoraCommands
from shared.alerts import AlertManager, Alert, AlertType from shared.alerts import AlertManager, Alert, AlertType
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer, rgb_lcd from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer, rgb_lcd, gps, rfid_reader
from lib.systemd_logs import get_systemd_logs from lib.systemd_logs import get_systemd_logs
from external_display_manager import DisplayManager from external_display_manager import DisplayManager
from rgb_lcd_manager import RGBLCDManager from rgb_lcd_manager import RGBLCDManager
@@ -21,6 +20,7 @@ rgb_lcd.setText("SmartWave\nOrchestrateur")
# --- CONFIGURATION CONSTANTS --- # --- CONFIGURATION CONSTANTS ---
DISPLAY_DEBOUNCE_SECONDS = 3 # Seconds to wait after first change before broadcasting DISPLAY_DEBOUNCE_SECONDS = 3 # Seconds to wait after first change before broadcasting
TECHNICIAN_MODE = False
# --- DB SETUP --- # --- DB SETUP ---
DB_PATH = "orchestrateur/db.sqlite" DB_PATH = "orchestrateur/db.sqlite"
@@ -145,6 +145,9 @@ mqtt_client.connect()
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS) mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
# RFID reader
rfid = rfid_reader.RFIDReader(rx_pin=17)
# DHT sensor warmup # DHT sensor warmup
time.sleep(2) # Allow GrovePi MCU and DHT sensor circuits to settle post-service start time.sleep(2) # Allow GrovePi MCU and DHT sensor circuits to settle post-service start
temp_hum.get_temperature_and_humidity() # Dummy read to flush initial NaN state temp_hum.get_temperature_and_humidity() # Dummy read to flush initial NaN state
@@ -174,7 +177,7 @@ if hasattr(cloud_mqtt_client._client, "loop_start"):
print("[MQTT Cloud] Paho background loop started.") print("[MQTT Cloud] Paho background loop started.")
# --- BACKGROUND TASKS (PRODUCERS) --- # --- BACKGROUND TASKS ---
async def lora_listener_task(): async def lora_listener_task():
"""Polls LoRa and pushes to the async queue.""" """Polls LoRa and pushes to the async queue."""
print("[LoRa] Async listener started.") print("[LoRa] Async listener started.")
@@ -230,32 +233,47 @@ async def cloud_mqtt_listener_task():
}) })
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# === LCD DISPLAY === async def rfid_listener_task():
def on_screens_change(screens, is_removed: bool, modified_name: str): """Polls RFID reader, verifies cards via OData API, and toggles TECHNICIAN_MODE."""
"""Callback for when screens are added or removed.""" global TECHNICIAN_MODE
log(f"[DisplayManager] Screens changed. Current screens: {list(screens.keys())}") print("[RFID] Async task started on GPIO 17.")
rgb_lcd_manager.set_external_screen_count(len(screens))
# Add Or remove from database of connected components last_scanned_tag = None
# 1. Extract id from name (assuming format "smartwave-epaper-<id>._displaytcp._tcp.local") last_scan_time = 0
display_id = modified_name.split("-")[-1].split(".")[0]
if is_removed:
remove_connected_component(display_id)
else:
save_connected_component(display_id, "external_display")
display_manager.set_on_screens_change_callback(on_screens_change)
def update_lcd_microwave_count(): while True:
"""Updates the LCD with the current number of connected microwaves.""" try:
global microwave_states # Quick non-blocking read via pigpio IPC
count = len(microwave_states) tag_id = await asyncio.to_thread(rfid.read_tag)
rgb_lcd_manager.set_microwave_count(count) current_time = time.time()
def update_lcd_cloud_alert(): if tag_id:
"""Updates the LCD with the current cloud connectivity status.""" # Debounce same tag within 3 seconds to avoid spamming the API
global cloud_alert if tag_id == last_scanned_tag and (current_time - last_scan_time) < 3.0:
rgb_lcd_manager.set_cloud_alert(cloud_alert) await asyncio.sleep(0.1)
continue
last_scanned_tag = tag_id
last_scan_time = current_time
print(f"[RFID] Tag detected: {tag_id}. Verifying with API...")
is_valid = await check_rfid_card_api(tag_id)
if is_valid:
TECHNICIAN_MODE = True
print(f"[RFID] Card {tag_id} VALIDATED! -> TECHNICIAN_MODE = TRUE")
# Optional: Sound positive feedback beep
buzzer.buzzer_siren(beatsNb=1)
else:
print(f"[RFID] Card {tag_id} REJECTED / Invalid.")
# Optional: Sound error feedback beep
buzzer.buzzer_siren(beatsNb=2)
except Exception as e:
print(f"[RFID Task] Error reading RFID tag: {e}")
# Sleep 100ms between checks to keep CPU usage near zero
await asyncio.sleep(0.1)
async def display_broadcast_worker_task(): async def display_broadcast_worker_task():
""" """
@@ -288,7 +306,137 @@ async def display_broadcast_worker_task():
microwave_states=microwave_states, microwave_states=microwave_states,
cloud_alert=cloud_alert cloud_alert=cloud_alert
) )
async def process_messages_task():
"""Consumes the unified event queue."""
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
while True:
msg = await async_event_queue.get()
source = msg["source"]
data = msg["data"]
if source == "LoRa":
if "new_cooking_state" in data.get("data", {}):
mw_id = data["data"].get("id")
n_state = data["data"].get("new_cooking_state")
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
microwave_states[mw_id].set_cooking_state(n_state)
update_lcd_microwave_count() # Update the LCD with new microwave count
if n_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
beats = 5 if n_state == CookingStates.ALERT else (1 if n_state == CookingStates.STIRRING_REQUIRED else 3)
buzzer.buzzer_siren(beatsNb=beats)
if n_state == CookingStates.IDLE and microwave_states.get(mw_id).state == MicrowaveState.COOKING:
microwave_states[mw_id].set_state(MicrowaveState.DONE)
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
if "action" in data.get("data", {}):
if type(data["data"]) != dict:
print(f"[LoRa] Invalid data format received: {data['data']}")
continue
action = data["data"]["action"]
if action == LoraCommands.COOKING_UPDATE:
mw_id = data["data"].get("id")
if mw_id in microwave_states:
state_info = data["data"]
microwave_states[mw_id].set_cooking_state(state_info.get("cooking_state", microwave_states[mw_id].cooking_state))
microwave_states[mw_id].set_cooking_estimated_remaining_time(state_info.get("estimated_remaining_time", microwave_states[mw_id].cooking_estimated_remaining_time))
microwave_states[mw_id].set_paused(state_info.get("paused", microwave_states[mw_id].paused))
elif action == LoraCommands.NEW_ALERT:
alert_type = data["data"].get("alert_type")
alert_message = data["data"].get("alert_message")
alert = Alert(alert_type, alert_message, redistributed=True)
alert_manager.add_alert(alert)
print(f"[LoRa] New alert received: {alert.to_dict()}")
else:
print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}")
elif source == "MQTT":
topic = msg["topic"]
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
if topic == hello_topic:
if data.get("id_orchestrator") != DEVICE_ID:
component_id = data.get("id_microwave")
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
if component_id:
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
# Offload DB insertion to async thread execution pool
await asyncio.to_thread(save_connected_component, component_id, component_type)
# Pass change callback to newly connected microwave
microwave_states[component_id] = MicrowaveState(
component_id,
on_change_callback=on_microwave_change
)
notify_display_update() # Update the external screens
update_lcd_microwave_count() # Update the LCD with new microwave count
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
qos=config.MQTT_QOS
)
elif topic == sensor_topic:
mw_id = str(data.get("id_microwave"))
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
# Store IR data and notify the waiting dish handler
ir_data_cache[mw_id] = data
if mw_id in ir_data_events:
ir_data_events[mw_id].set()
elif source == "CloudMQTT":
topic = msg["topic"]
print(f"[CloudMQTT] Command received on '{topic}': {data}")
action = data.get("action")
if action == "request_telemetry":
endpoint = data.get("endpoint")
if endpoint:
# Schedule as a background task so processing the queue isn't stalled
do_timeout = topic == "cmd/all"
asyncio.create_task(handle_telemetry_request(endpoint, do_timeout=do_timeout))
else:
print("[CloudMQTT] Received 'request_telemetry' but missing 'endpoint' field.")
else:
print(f"[CloudMQTT] Unknown action: {action}")
# === LCD DISPLAY ===
def on_screens_change(screens, is_removed: bool, modified_name: str):
"""Callback for when screens are added or removed."""
log(f"[DisplayManager] Screens changed. Current screens: {list(screens.keys())}")
rgb_lcd_manager.set_external_screen_count(len(screens))
# Add Or remove from database of connected components
# 1. Extract id from name (assuming format "smartwave-epaper-<id>._displaytcp._tcp.local")
display_id = modified_name.split("-")[-1].split(".")[0]
if is_removed:
remove_connected_component(display_id)
else:
save_connected_component(display_id, "external_display")
display_manager.set_on_screens_change_callback(on_screens_change)
def update_lcd_microwave_count():
"""Updates the LCD with the current number of connected microwaves."""
global microwave_states
count = len(microwave_states)
rgb_lcd_manager.set_microwave_count(count)
def update_lcd_cloud_alert():
"""Updates the LCD with the current cloud connectivity status."""
global cloud_alert
rgb_lcd_manager.set_cloud_alert(cloud_alert)
# --- Button ---
def button_callback(): def button_callback():
"""Button physical interrupt callback.""" """Button physical interrupt callback."""
global button_state global button_state
@@ -428,6 +576,44 @@ async def handle_new_dish(microwave_id, detected_height):
finally: finally:
ir_data_events.pop(microwave_id, None) ir_data_events.pop(microwave_id, None)
# === API CALLS ===
async def check_rfid_card_api(card_id: str) -> bool:
"""Queries the OData API asynchronously to verify if card_id is valid."""
url = "https://smartwave.matthiasg.dev/rfid-card"
params = {
"$filter": f"card_id eq '{card_id}'"
}
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["X-API-Key"] = API_KEY
try:
response = await asyncio.to_thread(
requests.get,
url,
params=params,
headers=headers,
timeout=5
)
if response.status_code == 200:
set_cloud_alert(False) # Clear cloud alert on success
data = response.json()
if isinstance(data, dict):
items = data.get("value", data.get("data", []))
return len(items) > 0 if isinstance(items, list) else bool(data)
elif isinstance(data, list):
return len(data) > 0
return False
except Exception as e:
print(f"[RFID Task] OData API check error: {e}")
set_cloud_alert(True) # Set cloud alert on API connection failure
return False
async def request_cloud_cooking_plan(microwave_id, sensors_data): async def request_cloud_cooking_plan(microwave_id, sensors_data):
"""Sends all data to the cloud with up to 3 retries (330s timeout for AI generation).""" """Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
global cloud_alert, alert_manager global cloud_alert, alert_manager
@@ -535,107 +721,6 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
microwave_states[microwave_id].set_state(MicrowaveState.DONE) microwave_states[microwave_id].set_state(MicrowaveState.DONE)
# --- MAIN LOGIC TASKS --- # --- MAIN LOGIC TASKS ---
async def process_messages_task():
"""Consumes the unified event queue."""
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
while True:
msg = await async_event_queue.get()
source = msg["source"]
data = msg["data"]
if source == "LoRa":
if "new_cooking_state" in data.get("data", {}):
mw_id = data["data"].get("id")
n_state = data["data"].get("new_cooking_state")
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
microwave_states[mw_id].set_cooking_state(n_state)
update_lcd_microwave_count() # Update the LCD with new microwave count
if n_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
beats = 5 if n_state == CookingStates.ALERT else (1 if n_state == CookingStates.STIRRING_REQUIRED else 3)
buzzer.buzzer_siren(beatsNb=beats)
if n_state == CookingStates.IDLE and microwave_states.get(mw_id).state == MicrowaveState.COOKING:
microwave_states[mw_id].set_state(MicrowaveState.DONE)
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
if "action" in data.get("data", {}):
if type(data["data"]) != dict:
print(f"[LoRa] Invalid data format received: {data['data']}")
continue
action = data["data"]["action"]
if action == LoraCommands.COOKING_UPDATE:
mw_id = data["data"].get("id")
if mw_id in microwave_states:
state_info = data["data"]
microwave_states[mw_id].set_cooking_state(state_info.get("cooking_state", microwave_states[mw_id].cooking_state))
microwave_states[mw_id].set_cooking_estimated_remaining_time(state_info.get("estimated_remaining_time", microwave_states[mw_id].cooking_estimated_remaining_time))
microwave_states[mw_id].set_paused(state_info.get("paused", microwave_states[mw_id].paused))
elif action == LoraCommands.NEW_ALERT:
alert_type = data["data"].get("alert_type")
alert_message = data["data"].get("alert_message")
alert = Alert(alert_type, alert_message, redistributed=True)
alert_manager.add_alert(alert)
print(f"[LoRa] New alert received: {alert.to_dict()}")
else:
print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}")
elif source == "MQTT":
topic = msg["topic"]
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
if topic == hello_topic:
if data.get("id_orchestrator") != DEVICE_ID:
component_id = data.get("id_microwave")
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
if component_id:
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
# Offload DB insertion to async thread execution pool
await asyncio.to_thread(save_connected_component, component_id, component_type)
# Pass change callback to newly connected microwave
microwave_states[component_id] = MicrowaveState(
component_id,
on_change_callback=on_microwave_change
)
notify_display_update() # Update the external screens
update_lcd_microwave_count() # Update the LCD with new microwave count
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
qos=config.MQTT_QOS
)
elif topic == sensor_topic:
mw_id = str(data.get("id_microwave"))
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
# Store IR data and notify the waiting dish handler
ir_data_cache[mw_id] = data
if mw_id in ir_data_events:
ir_data_events[mw_id].set()
elif source == "CloudMQTT":
topic = msg["topic"]
print(f"[CloudMQTT] Command received on '{topic}': {data}")
action = data.get("action")
if action == "request_telemetry":
endpoint = data.get("endpoint")
if endpoint:
# Schedule as a background task so processing the queue isn't stalled
do_timeout = topic == "cmd/all"
asyncio.create_task(handle_telemetry_request(endpoint, do_timeout=do_timeout))
else:
print("[CloudMQTT] Received 'request_telemetry' but missing 'endpoint' field.")
else:
print(f"[CloudMQTT] Unknown action: {action}")
async def handle_telemetry_request(endpoint: str, do_timeout=True): async def handle_telemetry_request(endpoint: str, do_timeout=True):
""" """
@@ -787,7 +872,8 @@ async def main():
cloud_mqtt_listener_task(), cloud_mqtt_listener_task(),
process_messages_task(), process_messages_task(),
monitor_dish_height_task(), monitor_dish_height_task(),
display_broadcast_worker_task() display_broadcast_worker_task(),
rfid_listener_task() # <-- ADDED HERE
) )
if __name__ == "__main__": if __name__ == "__main__":
@@ -796,6 +882,7 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nArrêt manuel.") print("\nArrêt manuel.")
finally: finally:
rfid.close() # <-- ADDED CLEANUP HERE
asyncio.run(display_manager.stop()) asyncio.run(display_manager.stop())
if hasattr(mqtt_client._client, "loop_stop"): if hasattr(mqtt_client._client, "loop_stop"):
mqtt_client._client.loop_stop() mqtt_client._client.loop_stop()
+3 -1
View File
@@ -4,4 +4,6 @@ pyserial>=3.5,<4
# OpenCV # OpenCV
# sudo apt install -y python3-opencv # sudo apt install -y python3-opencv
# sudo apt install -y opencv-data # sudo apt install -y opencv-data
zeroconf>=0.131.0 zeroconf>=0.131.0
# sudo apt install pigpio python3-pigpio
# sudo systemctl enable pigpiod --now
+2 -1
View File
@@ -6,4 +6,5 @@ import sensors.button as button
import sensors.gps as gps import sensors.gps as gps
import sensors.camera as camera import sensors.camera as camera
import sensors.buzzer as buzzer import sensors.buzzer as buzzer
import sensors.rgb_lcd as rgb_lcd import sensors.rgb_lcd as rgb_lcd
import sensors.rfid_reader as rfid_reader
+95
View File
@@ -0,0 +1,95 @@
import time
import pigpio
from typing import Optional
from sensors.lock import serial_lock
class RFIDReader:
"""
Grove 125kHz RFID Reader using bit-banged software serial on GPIO 17 (Pin 11).
Parses 14-byte frame: [0x02 STX] + [10 ASCII ID] + [2 ASCII Checksum] + [0x03 ETX]
"""
def __init__(self, rx_pin: int = 17, baudrate: int = 9600):
self.rx_pin = rx_pin
self.baudrate = baudrate
self.buffer = bytearray()
self.pi = pigpio.pi()
if not self.pi.connected:
raise RuntimeError("pigpio daemon is not running. Run 'sudo systemctl start pigpiod'.")
with serial_lock:
self.pi.set_mode(self.rx_pin, pigpio.INPUT)
# Clean up lingering serial sessions on this pin
try:
self.pi.bb_serial_read_close(self.rx_pin)
except pigpio.error:
pass
self.pi.bb_serial_read_open(self.rx_pin, self.baudrate, 8)
def read_tag(self) -> Optional[str]:
"""
Reads and accumulates bytes, returning the 10-digit Tag ID.
"""
with serial_lock:
if not self.pi or not self.pi.connected:
return None
count, data = self.pi.bb_serial_read(self.rx_pin)
if count > 0:
self.buffer.extend(data)
# Look for Start-of-Text (0x02)
stx_idx = self.buffer.find(b"\x02")
if stx_idx != -1:
# Discard noise prior to 0x02
if stx_idx > 0:
self.buffer = self.buffer[stx_idx:]
# Wait for full 14-byte payload
if len(self.buffer) >= 14:
raw_frame = self.buffer[:14]
self.buffer = self.buffer[14:] # Flush parsed frame
# Verify End-of-Text (0x03)
if raw_frame[-1] == 0x03:
try:
# Extract 10-character ID (indices 1 through 10)
return raw_frame[1:11].decode("ascii")
except UnicodeDecodeError:
return None
else:
# Flush buffer if filled with noise without STX marker
if len(self.buffer) > 64:
self.buffer.clear()
return None
def close(self):
with serial_lock:
if self.pi and self.pi.connected:
try:
self.pi.bb_serial_read_close(self.rx_pin)
except pigpio.error:
pass
self.pi.stop()
if __name__ == "__main__":
reader = RFIDReader(rx_pin=17)
print("RFID Reader active on GPIO 17 (Pin 11). Swipe a tag...")
try:
while True:
tag = reader.read_tag()
if tag:
print(f"Scanned Tag ID: {tag}")
time.sleep(0.05)
except KeyboardInterrupt:
print("\nStopping reader.")
finally:
reader.close()