258 lines
7.9 KiB
Ruby
258 lines
7.9 KiB
Ruby
require 'i2c'
|
|
require 'cyw43'
|
|
require 'socket'
|
|
|
|
# -------------------------------------------------------------
|
|
# Configuration Loader
|
|
# -------------------------------------------------------------
|
|
begin
|
|
require './config'
|
|
rescue LoadError
|
|
puts "ERROR: config.rb not found."
|
|
puts "Create config.rb on the device with your Wi-Fi and MQTT credentials."
|
|
loop { sleep 2 }
|
|
end
|
|
|
|
STATE_TOPIC = "homeassistant/sensor/#{Config::NODE_ID}/state"
|
|
|
|
# -------------------------------------------------------------
|
|
# Minimal MQTT 3.1.1 Client
|
|
# -------------------------------------------------------------
|
|
class SimpleMQTT
|
|
def initialize(host, port = 1883)
|
|
@host = host
|
|
@port = port
|
|
@sock = nil
|
|
end
|
|
|
|
def connect(client_id, username = nil, password = nil)
|
|
@sock = TCPSocket.new(@host, @port)
|
|
|
|
# Flags: Clean session (bit 1) + optional credentials
|
|
flags = 0x02
|
|
flags |= 0x80 if username
|
|
flags |= 0x40 if password
|
|
|
|
# Variable header: Protocol Name (MQTT) + Level (4) + Flags + KeepAlive (60s)
|
|
var_header = [0x00, 0x04, 0x4D, 0x51, 0x54, 0x54, 0x04, flags, 0x00, 0x3C].pack('C*')
|
|
|
|
# Payload
|
|
payload = encode_str(client_id)
|
|
payload << encode_str(username) if username
|
|
payload << encode_str(password) if password
|
|
|
|
body = var_header + payload
|
|
packet = [0x10].pack('C') + encode_len(body.bytesize) + body
|
|
@sock.write(packet)
|
|
|
|
# Read CONNACK (4 bytes: 0x20, 0x02, ack_flags, return_code)
|
|
resp = @sock.read(4)
|
|
if resp.nil? || resp.getbyte(0) != 0x20 || resp.getbyte(3) != 0x00
|
|
code = resp ? resp.getbyte(3) : 'nil'
|
|
raise "MQTT connection failed: code #{code}"
|
|
end
|
|
end
|
|
|
|
def publish(topic, payload, retain = false)
|
|
cmd = 0x30 | (retain ? 0x01 : 0x00)
|
|
body = encode_str(topic) + payload.to_s
|
|
packet = [cmd].pack('C') + encode_len(body.bytesize) + body
|
|
@sock.write(packet)
|
|
end
|
|
|
|
def disconnect
|
|
@sock.write([0xE0, 0x00].pack('C*')) if @sock
|
|
@sock.close if @sock
|
|
rescue
|
|
nil
|
|
end
|
|
|
|
private
|
|
|
|
def encode_str(str)
|
|
[str.bytesize].pack('n') + str
|
|
end
|
|
|
|
def encode_len(len)
|
|
encoded = []
|
|
loop do
|
|
digit = len % 128
|
|
len /= 128
|
|
digit |= 0x80 if len > 0
|
|
encoded << digit
|
|
break if len == 0
|
|
end
|
|
encoded.pack('C*')
|
|
end
|
|
end
|
|
|
|
# -------------------------------------------------------------
|
|
# BME280 Driver (I2C)
|
|
# -------------------------------------------------------------
|
|
class BME280
|
|
DEFAULT_ADDR = 0x76
|
|
|
|
def initialize(i2c, addr = DEFAULT_ADDR)
|
|
@i2c = i2c
|
|
@addr = addr
|
|
|
|
# Reset sensor
|
|
@i2c.write(@addr, [0xE0, 0xB6].pack('C*'))
|
|
sleep 0.1
|
|
|
|
read_calibration_data
|
|
|
|
# Humidity oversampling x1
|
|
@i2c.write(@addr, [0xF2, 0x01].pack('C*'))
|
|
# Pressure oversampling x1, Temp oversampling x1, Normal mode
|
|
@i2c.write(@addr, [0xF4, 0x27].pack('C*'))
|
|
# Standby 1000ms, filter off
|
|
@i2c.write(@addr, [0xF5, 0xA0].pack('C*'))
|
|
end
|
|
|
|
def read_calibration_data
|
|
calib = @i2c.read_from_mem(@addr, 0x88, 24)
|
|
c = calib.unpack('S<s<s<S<s<s<S<s<s<')
|
|
@dig_t1, @dig_t2, @dig_t3 = c[0], c[1], c[2]
|
|
@dig_p1, @dig_p2, @dig_p3, @dig_p4, @dig_p5, @dig_p6 = c[3], c[4], c[5], c[6], c[7], c[8]
|
|
|
|
@dig_h1 = @i2c.read_from_mem(@addr, 0xA1, 1).unpack1('C')
|
|
h = @i2c.read_from_mem(@addr, 0xE1, 7).unpack('s<CBb<b<c')
|
|
@dig_h2, @dig_h3 = h[0], h[1]
|
|
e4 = @i2c.read_from_mem(@addr, 0xE4, 2).unpack('CC')
|
|
@dig_h4 = (e4[0] << 4) | (e4[1] & 0x0F)
|
|
@dig_h5 = (e4[1] >> 4) | (@i2c.read_from_mem(@addr, 0xE5, 1).unpack1('C') << 4)
|
|
@dig_h6 = @i2c.read_from_mem(@addr, 0xE7, 1).unpack1('c')
|
|
end
|
|
|
|
def read_values
|
|
raw = @i2c.read_from_mem(@addr, 0xF7, 8).unpack('C*')
|
|
raw_p = ((raw[0] << 16) | (raw[1] << 8) | raw[2]) >> 4
|
|
raw_t = ((raw[3] << 16) | (raw[4] << 8) | raw[5]) >> 4
|
|
raw_h = (raw[6] << 8) | raw[7]
|
|
|
|
# Temperature calculation (Celsius)
|
|
var1 = (((raw_t >> 3) - (@dig_t1 << 1)) * @dig_t2) >> 11
|
|
var2 = (((((raw_t >> 4) - @dig_t1) * ((raw_t >> 4) - @dig_t1)) >> 12) * @dig_t3) >> 14
|
|
t_fine = var1 + var2
|
|
temp = ((t_fine * 5 + 128) >> 8) / 100.0
|
|
|
|
# Pressure calculation (hPa)
|
|
p_var1 = t_fine - 128000
|
|
p_var2 = p_var1 * p_var1 * @dig_p6 + ((p_var1 * @dig_p5) << 17) + (@dig_p4 << 35)
|
|
p_var1 = ((p_var1 * p_var1 * @dig_p3) >> 8) + ((p_var1 * @dig_p2) << 12)
|
|
p_var1 = (((1 << 47) + p_var1) * @dig_p1) >> 33
|
|
|
|
pressure = if p_var1 == 0
|
|
0
|
|
else
|
|
p = 1048576 - raw_p
|
|
p = (((p << 31) - p_var2) * 3125) / p_var1
|
|
p_var1 = (@dig_p9 * (p >> 13) * (p >> 13)) >> 25
|
|
p_var2 = (@dig_p8 * p) >> 19
|
|
((p + p_var1 + p_var2) >> 8) + (@dig_p7 << 4)
|
|
(p / 256.0) / 100.0
|
|
end
|
|
|
|
# Humidity calculation (%)
|
|
h_var = t_fine - 76800
|
|
h_var = (((((raw_h << 14) - (@dig_h4 << 20) - (@dig_h5 * h_var)) + 16384) >> 15) *
|
|
(((((((h_var * @dig_h6) >> 10) * (((h_var * @dig_h3) >> 11) + 32768)) >> 10) + 2097152) *
|
|
@dig_h2 + 8192) >> 14))
|
|
h_var = h_var - (((((h_var >> 15) * (h_var >> 15)) >> 7) * @dig_h1) >> 4)
|
|
h_var = 0 if h_var < 0
|
|
h_var = 419430400 if h_var > 419430400
|
|
humidity = (h_var >> 12) / 1024.0
|
|
|
|
{
|
|
temperature: temp.round(2),
|
|
pressure: pressure.round(2),
|
|
humidity: humidity.round(2)
|
|
}
|
|
end
|
|
end
|
|
|
|
# -------------------------------------------------------------
|
|
# Home Assistant MQTT Discovery
|
|
# -------------------------------------------------------------
|
|
def register_ha_discovery(mqtt)
|
|
device_info = "\"device\":{\"identifiers\":[\"#{Config::NODE_ID}\"],\"name\":\"Pico 2W Weather\",\"model\":\"Pico 2 W\",\"manufacturer\":\"Raspberry Pi\"}"
|
|
|
|
configs = [
|
|
{
|
|
id: "temperature",
|
|
name: "Temperature",
|
|
unit: "°C",
|
|
cls: "temperature",
|
|
val_tpl: "{{ value_json.temperature }}"
|
|
},
|
|
{
|
|
id: "humidity",
|
|
name: "Humidity",
|
|
unit: "%",
|
|
cls: "humidity",
|
|
val_tpl: "{{ value_json.humidity }}"
|
|
},
|
|
{
|
|
id: "pressure",
|
|
name: "Pressure",
|
|
unit: "hPa",
|
|
cls: "atmospheric_pressure",
|
|
val_tpl: "{{ value_json.pressure }}"
|
|
}
|
|
]
|
|
|
|
configs.each do |c|
|
|
topic = "homeassistant/sensor/#{Config::NODE_ID}/#{c[:id]}/config"
|
|
payload = "{\"name\":\"#{c[:name]}\",\"state_topic\":\"#{STATE_TOPIC}\",\"unit_of_measurement\":\"#{c[:unit]}\",\"device_class\":\"#{c[:cls]}\",\"value_template\":\"#{c[:val_tpl]}\",\"unique_id\":\"#{Config::NODE_ID}_#{c[:id]}\",#{device_info}}"
|
|
mqtt.publish(topic, payload, true)
|
|
end
|
|
end
|
|
|
|
# -------------------------------------------------------------
|
|
# Hardware & Network Init
|
|
# -------------------------------------------------------------
|
|
puts "Connecting to Wi-Fi..."
|
|
CYW43.init
|
|
CYW43.enable_sta_mode
|
|
CYW43.connect_ap(Config::WIFI_SSID, Config::WIFI_PASS)
|
|
|
|
while !CYW43.tcpip_link_status_up?
|
|
sleep 1
|
|
print "."
|
|
end
|
|
puts "\nConnected! IP: #{CYW43.ip_address}"
|
|
|
|
# GP4 = SDA, GP5 = SCL
|
|
i2c = I2C.new(unit: :RP2040_I2C0, sda_pin: 4, scl_pin: 5, frequency: 100_000)
|
|
sensor = BME280.new(i2c)
|
|
|
|
mqtt = SimpleMQTT.new(Config::MQTT_BROKER, Config::MQTT_PORT)
|
|
puts "Connecting to MQTT broker at #{Config::MQTT_BROKER}..."
|
|
mqtt.connect(Config::CLIENT_ID, Config::MQTT_USER, Config::MQTT_PASS)
|
|
puts "Connected! Registering Home Assistant discovery..."
|
|
|
|
register_ha_discovery(mqtt)
|
|
|
|
# -------------------------------------------------------------
|
|
# Main Loop
|
|
# -------------------------------------------------------------
|
|
loop do
|
|
data = sensor.read_values
|
|
puts "Temp: #{data[:temperature]} °C | Hum: #{data[:humidity]} % | Press: #{data[:pressure]} hPa"
|
|
|
|
payload = "{\"temperature\":#{data[:temperature]},\"humidity\":#{data[:humidity]},\"pressure\":#{data[:pressure]}}"
|
|
mqtt.publish(STATE_TOPIC, payload)
|
|
|
|
sleep 30
|
|
rescue => e
|
|
puts "Error in telemetry loop: #{e.message}. Attempting reconnect..."
|
|
sleep 5
|
|
begin
|
|
mqtt.disconnect
|
|
mqtt.connect(Config::CLIENT_ID, Config::MQTT_USER, Config::MQTT_PASS)
|
|
rescue
|
|
nil
|
|
end
|
|
end
|