# ============================================================
# BAS GH01 - FIELD LAYER
# Firmware v1.3.1
# Change history v1.3.1 (2026-10-18):
# - Retain MQTT QoS 1 as agreed with system owner.
# - Telemetry publish changed from QoS 0 to QoS 1 so MQTT
# publish/subscribe QoS configuration is consistent.
# - Local fallback, thresholds, validation, reconnect, and payload
# compatibility from v1.3 are otherwise retained.
#
# REMOTE:
# Automation Layer controls the fan via MQTT.
#
# LOCAL_FALLBACK:
# If Wi-Fi OR MQTT is disconnected, local temperature
# hysteresis controls the fan until a valid remote command
# is accepted after connectivity is restored.
#
# MQTT compatibility:
# telemetry: message_id retained
# fan/status: message_id, desired_state, output_state,
# fan_state, plus additional metadata
#
# Safety policy:
# Local fallback thresholds: ON >= 30 C, OFF <= 28 C
# Invalid DHT22 reading: hold last output state
# No physical fan feedback: fan_actual = None
# ============================================================
import network
import machine
import dht
import time
import json
import ubinascii
import ntptime
from machine import Pin
from umqtt.simple import MQTTClient
# ------------------------------------------------------------
# 1. CONFIGURATION
# ------------------------------------------------------------
ZONE_ID = "GH01"
SCHEMA_VERSION = "1.0"
WIFI_SSID = "Wokwi-GUEST"
WIFI_PASSWORD = ""
MQTT_BROKER = "x08f926c.ala.us-east-1.emqxsl.com"
MQTT_PORT = 8883
MQTT_USERNAME = "kelompok2"
MQTT_PASSWORD = "jHYw-t.H8Z44zUf"
DEVICE_ID = ubinascii.hexlify(
machine.unique_id()
).decode()
MQTT_CLIENT_ID = "gh01-field-" + DEVICE_ID
TOPIC_TELEMETRY = b"gh01/telemetry"
TOPIC_FAN_COMMAND = b"gh01/fan/command"
TOPIC_FAN_STATUS = b"gh01/fan/status"
DHT_PIN = 15
RELAY_PIN = 2
# False = active HIGH; True = active LOW
RELAY_ACTIVE_LOW = False
BOOT_FAN_STATE = "OFF"
# Local fallback thresholds; confirm with system owner.
LOCAL_FAN_ON_TEMP_C = 30.0
LOCAL_FAN_OFF_TEMP_C = 28.0
# Periodic tasks
TELEMETRY_INTERVAL_MS = 5000
STATUS_INTERVAL_MS = 10000
SENSOR_INTERVAL_MS = 5000
COMMAND_TIMEOUT_MS = 30000
NTP_RETRY_INTERVAL_MS = 60000
NETWORK_RETRY_INTERVAL_MS = 5000
MAX_COMMAND_VALID_FOR_S = 300
MAX_FUTURE_CLOCK_SKEW_S = 5
MIN_VALID_YEAR = 2024
# ------------------------------------------------------------
# 2. HARDWARE INITIALIZATION
# ------------------------------------------------------------
if BOOT_FAN_STATE not in ("ON", "OFF"):
raise ValueError("BOOT_FAN_STATE must be ON or OFF")
if LOCAL_FAN_OFF_TEMP_C >= LOCAL_FAN_ON_TEMP_C:
raise ValueError("Local hysteresis thresholds are invalid")
sensor = dht.DHT22(Pin(DHT_PIN))
if BOOT_FAN_STATE == "ON":
initial_pin_value = 0 if RELAY_ACTIVE_LOW else 1
else:
initial_pin_value = 1 if RELAY_ACTIVE_LOW else 0
relay = Pin(
RELAY_PIN,
Pin.OUT,
value=initial_pin_value
)
wifi = network.WLAN(network.STA_IF)
wifi.active(True)
mqtt_client = None
fan_state = BOOT_FAN_STATE
# Legacy field: last requested/applied state.
desired_state = BOOT_FAN_STATE
control_mode = "AUTO"
# REMOTE or LOCAL_FALLBACK
control_source = "REMOTE"
# True while connectivity is restored but a valid remote
# command has not yet taken control.
recovery_pending = False
clock_synced = False
last_valid_command_ms = None
command_timeout_active = False
# Last successfully applied remote command
last_command_id = None
last_command_timestamp = None
# Most recently processed command
last_command_result = "NONE"
last_received_command_id = None
last_received_desired_state = None
last_received_command_timestamp = None
last_event = "BOOT"
last_sensor_status = "NOT_READ"
last_temperature_c = None
last_humidity_rh = None
status_dirty = True
# Publication sequence counter
message_sequence = 0
# Reconnection and periodic task timers
now0 = time.ticks_ms()
last_wifi_attempt_ms = (
now0 - NETWORK_RETRY_INTERVAL_MS
)
last_mqtt_attempt_ms = (
now0 - NETWORK_RETRY_INTERVAL_MS
)
last_ntp_attempt_ms = (
now0 - NTP_RETRY_INTERVAL_MS
)
last_sensor_ms = (
now0 - SENSOR_INTERVAL_MS
)
last_telemetry_ms = (
now0 - TELEMETRY_INTERVAL_MS
)
last_status_ms = (
now0 - STATUS_INTERVAL_MS
)
# ------------------------------------------------------------
# 3. GENERAL HELPERS
# ------------------------------------------------------------
def ticks_elapsed(now, previous):
return time.ticks_diff(now, previous)
def new_message_id(prefix):
global message_sequence
message_sequence += 1
return "{}-{}-{:08d}".format(
prefix,
DEVICE_ID,
message_sequence
)
def set_relay(state):
global fan_state
if state not in ("ON", "OFF"):
raise ValueError("Invalid fan state")
if state == "ON":
pin_value = 0 if RELAY_ACTIVE_LOW else 1
else:
pin_value = 1 if RELAY_ACTIVE_LOW else 0
relay.value(pin_value)
fan_state = state
def clock_year_is_valid():
try:
return time.gmtime()[0] >= MIN_VALID_YEAR
except Exception:
return False
def timestamp_utc():
if not clock_synced:
return None
try:
t = time.gmtime()
if t[0] < MIN_VALID_YEAR:
return None
return (
"{:04d}-{:02d}-{:02d}T"
"{:02d}:{:02d}:{:02d}Z"
).format(
t[0], t[1], t[2],
t[3], t[4], t[5]
)
except Exception:
return None
def parse_utc_timestamp(value):
if not isinstance(value, str):
raise ValueError("Timestamp must be a string")
if len(value) != 20:
raise ValueError("Invalid timestamp length")
if value[10] != "T" or value[19] != "Z":
raise ValueError("Timestamp must be UTC ISO-8601")
year = int(value[0:4])
month = int(value[5:7])
day = int(value[8:10])
hour = int(value[11:13])
minute = int(value[14:16])
second = int(value[17:19])
if year < MIN_VALID_YEAR:
raise ValueError("Invalid timestamp year")
if month < 1 or month > 12:
raise ValueError("Invalid timestamp month")
if day < 1 or day > 31:
raise ValueError("Invalid timestamp day")
if hour > 23 or minute > 59 or second > 59:
raise ValueError("Invalid timestamp time")
return time.mktime((
year, month, day,
hour, minute, second,
0, 0
))
def mark_status_changed(event_name=None):
global status_dirty, last_event
status_dirty = True
if event_name is not None:
last_event = event_name
def publish_json(topic, payload, qos=1):
global mqtt_client
if mqtt_client is None:
return False
try:
mqtt_client.publish(
topic,
json.dumps(payload),
retain=False,
qos=qos
)
return True
except Exception as exc:
print("MQTT publish error:", exc)
mqtt_client = None
return False
# ------------------------------------------------------------
# 4. CONNECTION STATE AND LOCAL FALLBACK
# ------------------------------------------------------------
def remote_path_available():
return wifi.isconnected() and mqtt_client is not None
def update_control_source():
"""
Enter fallback if either Wi-Fi or MQTT is unavailable.
Once connectivity is restored, remain in fallback until
a valid remote command is accepted. This avoids an abrupt
handover to an unknown/stale remote output request.
"""
global control_source, recovery_pending, mqtt_client
if not wifi.isconnected():
# The previous socket is no longer usable.
mqtt_client = None
if not remote_path_available():
if control_source != "LOCAL_FALLBACK":
control_source = "LOCAL_FALLBACK"
recovery_pending = False
mark_status_changed(
"LOCAL_FALLBACK_ENTERED"
)
print(
"LOCAL FALLBACK active:",
"Wi-Fi/MQTT unavailable"
)
return
# Both Wi-Fi and MQTT are connected.
# Keep local control active until a valid command arrives.
if control_source == "LOCAL_FALLBACK":
if not recovery_pending:
recovery_pending = True
mark_status_changed(
"RECOVERY_WAITING_FOR_COMMAND"
)
print(
"Connectivity restored;",
"waiting for valid Automation command"
)
def apply_local_temperature_control():
"""
Local fallback temperature hysteresis.
Invalid sensor reading:
hold the last output state and report the condition.
Valid sensor reading:
temperature >= ON threshold -> fan ON
temperature <= OFF threshold -> fan OFF
between thresholds -> hold the last output state
"""
global desired_state
if control_source != "LOCAL_FALLBACK":
return
if last_sensor_status != "OK" or \
last_temperature_c is None:
mark_status_changed(
"LOCAL_SENSOR_INVALID_HOLD_LAST_STATE"
)
return
old_state = fan_state
temp = last_temperature_c
if temp >= LOCAL_FAN_ON_TEMP_C:
if fan_state != "ON":
set_relay("ON")
desired_state = "ON"
elif temp <= LOCAL_FAN_OFF_TEMP_C:
if fan_state != "OFF":
set_relay("OFF")
desired_state = "OFF"
# Between thresholds, retain the current output state.
if fan_state != old_state:
mark_status_changed(
"LOCAL_FALLBACK_TEMP_CONTROL"
)
print(
"Local fallback:",
temp,
"C -> fan",
fan_state
)
# ------------------------------------------------------------
# 5. WIFI RECONNECT - NON-BLOCKING ATTEMPT
# ------------------------------------------------------------
def start_wifi_attempt_if_due():
global last_wifi_attempt_ms
if wifi.isconnected():
return
now = time.ticks_ms()
if ticks_elapsed(
now, last_wifi_attempt_ms
) < NETWORK_RETRY_INTERVAL_MS:
return
last_wifi_attempt_ms = now
try:
print("Attempting Wi-Fi connection...")
wifi.active(True)
wifi.connect(WIFI_SSID, WIFI_PASSWORD)
except Exception as exc:
# Some ports raise an error if connect() is called
# while an earlier attempt is still in progress.
print("Wi-Fi connect attempt:", exc)
# ------------------------------------------------------------
# 6. NTP RETRY
# ------------------------------------------------------------
def retry_ntp_if_due():
global clock_synced, last_ntp_attempt_ms
if not wifi.isconnected():
return
if clock_synced and clock_year_is_valid():
return
now = time.ticks_ms()
if ticks_elapsed(
now, last_ntp_attempt_ms
) < NTP_RETRY_INTERVAL_MS:
return
last_ntp_attempt_ms = now
try:
print("Attempting NTP synchronization...")
ntptime.settime()
if clock_year_is_valid():
clock_synced = True
mark_status_changed("NTP_SYNCED")
print("NTP synchronized:", timestamp_utc())
else:
clock_synced = False
print("NTP returned an invalid clock")
except Exception as exc:
clock_synced = False
print("NTP synchronization failed:", exc)
# ------------------------------------------------------------
# 7. STATUS PAYLOAD - LEGACY + NEW FIELDS
# ------------------------------------------------------------
def build_status_payload():
if last_valid_command_ms is None:
command_age_s = None
else:
command_age_s = ticks_elapsed(
time.ticks_ms(),
last_valid_command_ms
) // 1000
return {
"schema_version": SCHEMA_VERSION,
"zone_id": ZONE_ID,
"message_id": new_message_id("gh01-sts"),
"timestamp": timestamp_utc(),
# Backward-compatible output fields
"desired_state": desired_state,
"output_state": fan_state,
"fan_state": fan_state,
# Physical fan feedback is not installed
"fan_actual": None,
# AUTO/MANUAL is the requested operating mode.
# control_source identifies who currently controls output.
"control_mode": control_mode,
"control_source": control_source,
"recovery_pending": recovery_pending,
"wifi_connected": wifi.isconnected(),
"mqtt_connected": mqtt_client is not None,
"clock_synced": clock_synced,
# Last successfully applied remote command
"last_command_id": last_command_id,
"last_command_timestamp": last_command_timestamp,
# Most recently processed command
"last_command_result": last_command_result,
"last_received_command_id": last_received_command_id,
"last_received_desired_state": last_received_desired_state,
"last_received_command_timestamp":
last_received_command_timestamp,
# Command watchdog
"command_timeout_s": COMMAND_TIMEOUT_MS // 1000,
"command_timeout_active": command_timeout_active,
"last_valid_command_age_s": command_age_s,
# Local fallback configuration and sensor state
"local_fallback_on_temp_c": LOCAL_FAN_ON_TEMP_C,
"local_fallback_off_temp_c": LOCAL_FAN_OFF_TEMP_C,
"temperature_c": last_temperature_c,
"humidity_rh": last_humidity_rh,
"sensor_status": last_sensor_status,
"last_event": last_event
}
def publish_status(force=False):
global status_dirty
if not force and not status_dirty:
return
if publish_json(
TOPIC_FAN_STATUS,
build_status_payload(),
qos=1
):
status_dirty = False
# ------------------------------------------------------------
# 8. COMMAND VALIDATION
# ------------------------------------------------------------
def reject_command(command_id, result, event_name,
received_state=None,
received_timestamp=None):
global last_command_result
global last_received_command_id
global last_received_desired_state
global last_received_command_timestamp
last_received_command_id = command_id
last_received_desired_state = received_state
last_received_command_timestamp = received_timestamp
last_command_result = result
mark_status_changed(event_name)
def on_mqtt_message(topic, msg):
global desired_state, control_mode
global control_source, recovery_pending
global last_command_id, last_command_timestamp
global last_command_result
global last_received_command_id
global last_received_desired_state
global last_received_command_timestamp
global last_valid_command_ms
global command_timeout_active
try:
payload = json.loads(msg.decode())
except Exception:
reject_command(
None,
"REJECTED_INVALID_JSON",
"COMMAND_REJECTED"
)
return
command_id = payload.get("message_id")
requested_state = payload.get("desired_state")
requested_mode = payload.get("control_mode")
command_timestamp = payload.get("timestamp")
if payload.get("schema_version") != SCHEMA_VERSION:
reject_command(
command_id, "REJECTED_SCHEMA",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
if payload.get("zone_id") != ZONE_ID:
reject_command(
command_id, "REJECTED_ZONE",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
if not isinstance(command_id, str) or not command_id:
reject_command(
None, "REJECTED_INVALID",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
if requested_state not in ("ON", "OFF"):
reject_command(
command_id, "REJECTED_INVALID",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
if requested_mode not in ("AUTO", "MANUAL"):
reject_command(
command_id, "REJECTED_INVALID",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
valid_for_s = payload.get("valid_for_s")
if not isinstance(valid_for_s, int) or \
valid_for_s < 1 or \
valid_for_s > MAX_COMMAND_VALID_FOR_S:
reject_command(
command_id, "REJECTED_INVALID",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
# Duplicate protection for the last applied command.
if command_id == last_command_id:
reject_command(
command_id, "REJECTED_DUPLICATE",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
if not clock_synced or not clock_year_is_valid():
reject_command(
command_id, "REJECTED_CLOCK",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
try:
command_epoch = parse_utc_timestamp(command_timestamp)
age_s = time.time() - command_epoch
except Exception:
reject_command(
command_id, "REJECTED_TIMESTAMP",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
if age_s > valid_for_s:
reject_command(
command_id, "REJECTED_EXPIRED",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
if age_s < -MAX_FUTURE_CLOCK_SKEW_S:
reject_command(
command_id, "REJECTED_FUTURE_TIMESTAMP",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
# Apply remote command only after all validation succeeds.
try:
set_relay(requested_state)
except Exception as exc:
print("Relay output error:", exc)
reject_command(
command_id, "REJECTED_OUTPUT_ERROR",
"COMMAND_REJECTED",
requested_state, command_timestamp
)
return
desired_state = requested_state
control_mode = requested_mode
last_command_id = command_id
last_command_timestamp = command_timestamp
last_received_command_id = command_id
last_received_desired_state = requested_state
last_received_command_timestamp = command_timestamp
last_command_result = "ACCEPTED"
last_valid_command_ms = time.ticks_ms()
command_timeout_active = False
# A valid remote command completes the handover.
was_in_fallback = (control_source == "LOCAL_FALLBACK")
control_source = "REMOTE"
recovery_pending = False
if was_in_fallback:
mark_status_changed("REMOTE_CONTROL_RESTORED")
else:
mark_status_changed("COMMAND_ACCEPTED")
print(
"Remote command accepted:",
command_id,
"state =", fan_state,
"mode =", control_mode
)
# ------------------------------------------------------------
# 9. MQTT CONNECTION
# ------------------------------------------------------------
def connect_mqtt():
global mqtt_client
if not wifi.isconnected():
return False
try:
client = MQTTClient(
client_id=MQTT_CLIENT_ID,
server=MQTT_BROKER,
port=MQTT_PORT,
user=MQTT_USERNAME,
password=MQTT_PASSWORD,
keepalive=60,
ssl=True,
ssl_params={
"server_hostname": MQTT_BROKER
}
)
client.set_callback(on_mqtt_message)
client.connect()
client.subscribe(TOPIC_FAN_COMMAND, qos=1)
mqtt_client = client
print("MQTT connected")
print("Subscribed:", TOPIC_FAN_COMMAND)
mark_status_changed("MQTT_CONNECTED")
return True
except Exception as exc:
print("MQTT connection error:", exc)
mqtt_client = None
return False
# ------------------------------------------------------------
# 10. SENSOR AND TELEMETRY
# ------------------------------------------------------------
def read_sensor():
global last_temperature_c
global last_humidity_rh
global last_sensor_status
try:
sensor.measure()
temp = sensor.temperature()
humidity = sensor.humidity()
if temp < -40 or temp > 80:
raise ValueError("Temperature outside DHT22 range")
if humidity < 0 or humidity > 100:
raise ValueError("Humidity outside DHT22 range")
last_temperature_c = round(temp, 2)
last_humidity_rh = round(humidity, 2)
last_sensor_status = "OK"
except Exception as exc:
print("DHT22 read error:", exc)
last_temperature_c = None
last_humidity_rh = None
last_sensor_status = "READ_ERROR"
mark_status_changed("SENSOR_UPDATED")
def publish_telemetry():
payload = {
"schema_version": SCHEMA_VERSION,
"zone_id": ZONE_ID,
"message_id": new_message_id("gh01-tlm"),
"timestamp": timestamp_utc(),
"temperature_c": last_temperature_c,
"humidity_rh": last_humidity_rh,
"sensor_valid": last_sensor_status == "OK",
"sensor_status": last_sensor_status
}
publish_json(
TOPIC_TELEMETRY,
payload,
qos=1 # CHANGE v1.3.1: retain QoS 1 as agreed with owner
)
# ------------------------------------------------------------
# 11. COMMAND WATCHDOG
# ------------------------------------------------------------
def check_command_timeout():
global command_timeout_active
if last_valid_command_ms is None:
return
if command_timeout_active:
return
age_ms = ticks_elapsed(
time.ticks_ms(),
last_valid_command_ms
)
if age_ms >= COMMAND_TIMEOUT_MS:
command_timeout_active = True
# Existing timeout policy: hold the last output state.
# Local fallback is activated by loss of Wi-Fi/MQTT.
mark_status_changed(
"COMMAND_TIMEOUT_HOLD_LAST_STATE"
)
print(
"Remote command timeout.",
"Holding output state:",
fan_state
)
# ------------------------------------------------------------
# 12. MAIN LOOP
# ------------------------------------------------------------
def main():
global mqtt_client
global last_wifi_attempt_ms
global last_mqtt_attempt_ms
global last_sensor_ms
global last_telemetry_ms
global last_status_ms
global status_dirty
print("BAS GH01 Field Layer v1.3.1 starting (MQTT QoS 1)")
print("Device ID:", DEVICE_ID)
print("Local fallback ON >= ", LOCAL_FAN_ON_TEMP_C, "C")
print("Local fallback OFF <= ", LOCAL_FAN_OFF_TEMP_C, "C")
print("Command timeout:", COMMAND_TIMEOUT_MS // 1000, "seconds")
# Start an initial Wi-Fi connection attempt.
start_wifi_attempt_if_due()
while True:
now_ms = time.ticks_ms()
# A. Reconnect Wi-Fi without waiting in a long loop.
start_wifi_attempt_if_due()
# B. Update REMOTE / LOCAL_FALLBACK / recovery state.
update_control_source()
# C. Retry MQTT connection at a limited interval.
if wifi.isconnected() and mqtt_client is None:
if ticks_elapsed(
now_ms, last_mqtt_attempt_ms
) >= NETWORK_RETRY_INTERVAL_MS:
last_mqtt_attempt_ms = now_ms
connect_mqtt()
# D. Update control source again after connection attempt.
update_control_source()
# E. Retry NTP when Wi-Fi is available and clock is invalid.
retry_ntp_if_due()
# F. Receive remote commands.
if mqtt_client is not None:
try:
mqtt_client.check_msg()
except Exception as exc:
print("MQTT receive error:", exc)
mqtt_client = None
# MQTT loss immediately enables local fallback.
update_control_source()
# G. Read sensor periodically.
now_ms = time.ticks_ms()
if ticks_elapsed(
now_ms, last_sensor_ms
) >= SENSOR_INTERVAL_MS:
last_sensor_ms = now_ms
read_sensor()
# H. Execute local control only in fallback mode.
apply_local_temperature_control()
# I. Publish telemetry when broker is available.
if ticks_elapsed(
now_ms, last_telemetry_ms
) >= TELEMETRY_INTERVAL_MS:
last_telemetry_ms = now_ms
publish_telemetry()
# J. Existing remote command timeout.
check_command_timeout()
# K. Publish status periodically and after events.
now_ms = time.ticks_ms()
if ticks_elapsed(
now_ms, last_status_ms
) >= STATUS_INTERVAL_MS:
last_status_ms = now_ms
status_dirty = True
publish_status()
time.sleep_ms(100)
# ------------------------------------------------------------
# 13. START
# ------------------------------------------------------------
try:
main()
except KeyboardInterrupt:
print("Field Layer stopped by user")
except Exception as exc:
print("Fatal Field Layer error:", exc)
raise
Loading
esp32-devkit-c-v4
esp32-devkit-c-v4