Compare commits
33 Commits
v1.0.0
...
4213b6101a
| Author | SHA1 | Date | |
|---|---|---|---|
| 4213b6101a | |||
| e9b8d56f6d | |||
| a55d065c38 | |||
| eee68e4034 | |||
| c3dc5677b9 | |||
| fc0399a454 | |||
| 85af215d73 | |||
| c5e6187523 | |||
| 4000d0972e | |||
| e1c842b719 | |||
| f2ac55eac1 | |||
| 3e759d30ed | |||
| aeef9a424c | |||
| 36e91c7246 | |||
| 234391a881 | |||
| 427df1f034 | |||
| b467541eb5 | |||
| 16c47e62f5 | |||
| 5850089de9 | |||
| c8e8afff67 | |||
| 9b1229a2ee | |||
| 9de5f82924 | |||
| e3bec0d16e | |||
| 675c39eab3 | |||
| c1519b3eb5 | |||
| f36257226f | |||
| b740372d88 | |||
| d0ba2c5a52 | |||
| b2f9bff765 | |||
| f54c0a0f35 | |||
| c9b68dd8e2 | |||
| 426f1d3813 | |||
| 8cc2c41acf |
211
INSTALL.md
Normal file
211
INSTALL.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Installation Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Raspberry Pi with Raspberry Pi OS (Debian-based)
|
||||
- Python 3.9+
|
||||
- Bluetooth adapter
|
||||
- MQTT broker accessible on network
|
||||
- Xiaomi Mijia LYWSD03MMC sensors with ATC firmware
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repo-url> ~/sensorpajen
|
||||
cd ~/sensorpajen
|
||||
|
||||
# Create virtual environment and install
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
|
||||
# Configure
|
||||
cp config/sensorpajen.env.example config/sensorpajen.env
|
||||
cp config/sensors.json.example config/sensors.json
|
||||
nano config/sensorpajen.env # Set MQTT_HOST, credentials
|
||||
nano config/sensors.json # Add sensor MAC addresses
|
||||
|
||||
# Set Bluetooth capabilities
|
||||
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f .venv/bin/python3)
|
||||
|
||||
# Install systemd service
|
||||
mkdir -p ~/.config/systemd/user/
|
||||
cp systemd/sensorpajen.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
sudo loginctl enable-linger $USER
|
||||
|
||||
# Enable and start
|
||||
systemctl --user enable sensorpajen
|
||||
systemctl --user start sensorpajen
|
||||
```
|
||||
|
||||
## Verify Installation
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl --user status sensorpajen
|
||||
|
||||
# View logs
|
||||
journalctl --user -u sensorpajen -f
|
||||
|
||||
# Test MQTT (adjust credentials)
|
||||
mosquitto_sub -h MQTT_HOST -u USER -P PASSWORD -t "MiTemperature2/#" -v
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### MQTT Settings (`config/sensorpajen.env`)
|
||||
|
||||
```bash
|
||||
MQTT_HOST=192.168.1.10 # Required
|
||||
MQTT_PORT=1883
|
||||
MQTT_USER=username
|
||||
MQTT_PASSWORD=password
|
||||
MQTT_CLIENT_ID=sensorpajen
|
||||
MQTT_TOPIC_PREFIX=MiTemperature2
|
||||
|
||||
# Optional: ntfy notifications
|
||||
NTFY_ENABLED=false
|
||||
NTFY_URL=https://ntfy.sh
|
||||
NTFY_TOPIC=sensorpajen
|
||||
NTFY_TOKEN=
|
||||
|
||||
# Tuning
|
||||
WATCHDOG_TIMEOUT=5 # BLE scan restart timeout
|
||||
CONFIG_RELOAD_INTERVAL=900 # Auto-reload sensors (15 min)
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
### Sensors (`config/sensors.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"sensors": [
|
||||
{
|
||||
"mac": "A4:C1:38:12:34:56",
|
||||
"name": "Living Room",
|
||||
"comment": "Optional description"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Sensors
|
||||
|
||||
### Automatic Discovery (Recommended)
|
||||
|
||||
1. Power on new sensor near Raspberry Pi
|
||||
2. Wait for ntfy notification (if enabled) or check logs
|
||||
3. Run approval tool:
|
||||
```bash
|
||||
./scripts/approve-sensors.sh
|
||||
```
|
||||
4. Approve sensor with custom name
|
||||
5. Sensor auto-loaded within 15 minutes
|
||||
|
||||
### Manual Addition
|
||||
|
||||
1. Add to `config/sensors.json`
|
||||
2. Wait 15 minutes for auto-reload, or restart:
|
||||
```bash
|
||||
systemctl --user restart sensorpajen
|
||||
```
|
||||
|
||||
## Service Management
|
||||
|
||||
```bash
|
||||
# Start/stop
|
||||
systemctl --user start sensorpajen
|
||||
systemctl --user stop sensorpajen
|
||||
systemctl --user restart sensorpajen
|
||||
|
||||
# Enable/disable autostart
|
||||
systemctl --user enable sensorpajen
|
||||
systemctl --user disable sensorpajen
|
||||
|
||||
# Status and logs
|
||||
systemctl --user status sensorpajen
|
||||
journalctl --user -u sensorpajen -f # Follow
|
||||
journalctl --user -u sensorpajen -n 100 # Last 100 lines
|
||||
journalctl --user -u sensorpajen --since today # Today's logs
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Denied on Bluetooth
|
||||
|
||||
```bash
|
||||
# Verify capabilities
|
||||
getcap $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
||||
# Should show: cap_net_admin,cap_net_raw+eip
|
||||
|
||||
# If missing, set them
|
||||
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
||||
systemctl --user restart sensorpajen
|
||||
```
|
||||
|
||||
### MQTT Connection Failed
|
||||
|
||||
```bash
|
||||
# Test MQTT manually
|
||||
mosquitto_sub -h MQTT_HOST -u USER -P PASSWORD -t "test" -v
|
||||
|
||||
# Check credentials in config/sensorpajen.env
|
||||
# Check firewall on MQTT broker
|
||||
```
|
||||
|
||||
### No Sensor Data
|
||||
|
||||
```bash
|
||||
# Verify sensors visible
|
||||
sudo hcitool lescan
|
||||
|
||||
# Check sensor has ATC firmware (should show as ATC_XXXXXX)
|
||||
# Verify MAC addresses in sensors.json match actual sensors
|
||||
# Check battery level (low battery = intermittent connection)
|
||||
```
|
||||
|
||||
### Service Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs for errors
|
||||
journalctl --user -u sensorpajen -n 50
|
||||
|
||||
# Common issues:
|
||||
# - MQTT_HOST not set in config/sensorpajen.env
|
||||
# - sensors.json syntax error
|
||||
# - Bluetooth service not running: sudo systemctl start bluetooth
|
||||
```
|
||||
|
||||
## Updates
|
||||
|
||||
```bash
|
||||
cd ~/sensorpajen
|
||||
git pull
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
|
||||
# Reinstall service if systemd file changed
|
||||
cp systemd/sensorpajen.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user restart sensorpajen
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
# Stop and disable service
|
||||
systemctl --user stop sensorpajen
|
||||
systemctl --user disable sensorpajen
|
||||
|
||||
# Remove service file
|
||||
rm ~/.config/systemd/user/sensorpajen.service
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# Remove capabilities
|
||||
sudo setcap -r $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
||||
|
||||
# Delete repository
|
||||
rm -rf ~/sensorpajen
|
||||
```
|
||||
528
LYWSD03MMC.py
528
LYWSD03MMC.py
@@ -1,528 +0,0 @@
|
||||
#!/usr/bin/python3 -u
|
||||
#!/home/openhabian/Python3/Python-3.7.4/python -u
|
||||
#-u to unbuffer output. Otherwise when calling with nohup or redirecting output things are printed very lately or would even mixup
|
||||
|
||||
from bluepy import btle
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from collections import deque
|
||||
import threading
|
||||
import time
|
||||
import signal
|
||||
import traceback
|
||||
import math
|
||||
import logging
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
@dataclass
|
||||
class Measurement:
|
||||
temperature: float
|
||||
humidity: int
|
||||
voltage: float
|
||||
calibratedHumidity: int = 0
|
||||
battery: int = 0
|
||||
timestamp: int = 0
|
||||
sensorname: str = ""
|
||||
rssi: int = 0
|
||||
|
||||
def __eq__(self, other): #rssi may be different
|
||||
if self.temperature == other.temperature and self.humidity == other.humidity and self.calibratedHumidity == other.calibratedHumidity and self.battery == other.battery and self.voltage == other.voltage and self.sensorname == other.sensorname:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
measurements=deque()
|
||||
#globalBatteryLevel=0
|
||||
previousMeasurement=Measurement(0,0,0,0,0,0,0,0)
|
||||
identicalCounter=0
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
if args.atc:
|
||||
disable_le_scan(sock)
|
||||
os._exit(0)
|
||||
|
||||
def watchDog_Thread():
|
||||
global unconnectedTime
|
||||
global connected
|
||||
global pid
|
||||
while True:
|
||||
logging.debug("watchdog_Thread")
|
||||
logging.debug("unconnectedTime : " + str(unconnectedTime))
|
||||
logging.debug("connected : " + str(connected))
|
||||
logging.debug("pid : " + str(pid))
|
||||
now = int(time.time())
|
||||
if (unconnectedTime is not None) and ((now - unconnectedTime) > 60): #could also check connected is False, but this is more fault proof
|
||||
pstree=os.popen("pstree -p " + str(pid)).read() #we want to kill only bluepy from our own process tree, because other python scripts have there own bluepy-helper process
|
||||
logging.debug("PSTree: " + pstree)
|
||||
try:
|
||||
bluepypid=re.findall(r'bluepy-helper\((.*)\)',pstree)[0] #Store the bluepypid, to kill it later
|
||||
except IndexError: #Should not happen since we're now connected
|
||||
logging.debug("Couldn't find pid of bluepy-helper")
|
||||
os.system("kill " + bluepypid)
|
||||
logging.debug("Killed bluepy with pid: " + str(bluepypid))
|
||||
unconnectedTime = now #reset unconnectedTime to prevent multiple killings in a row
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def thread_SendingData():
|
||||
global previousMeasurement
|
||||
global measurements
|
||||
path = os.path.dirname(os.path.abspath(__file__))
|
||||
while True:
|
||||
try:
|
||||
mea = measurements.popleft()
|
||||
if (mea == previousMeasurement and identicalCounter < args.skipidentical): #only send data when it has changed or X identical data has been skipped, ~10 pakets per minute, 50 pakets --> writing at least every 5 minutes
|
||||
print("Measurements are identical don't send data\n")
|
||||
identicalCounter+=1
|
||||
continue
|
||||
identicalCounter=0
|
||||
fmt = "sensorname,temperature,humidity,voltage" #don't try to seperate by semicolon ';' os.system will use that as command seperator
|
||||
if ' ' in mea.sensorname:
|
||||
sensorname = '"' + mea.sensorname + '"'
|
||||
else:
|
||||
sensorname = mea.sensorname
|
||||
params = sensorname + " " + str(mea.temperature) + " " + str(mea.humidity) + " " + str(mea.voltage)
|
||||
if (args.TwoPointCalibration or args.offset): #would be more efficient to generate fmt only once
|
||||
fmt +=",humidityCalibrated"
|
||||
params += " " + str(mea.calibratedHumidity)
|
||||
if (args.battery):
|
||||
fmt +=",batteryLevel"
|
||||
params += " " + str(mea.battery)
|
||||
if (args.rssi):
|
||||
fmt +=",rssi"
|
||||
params += " " + str(mea.rssi)
|
||||
params += " " + str(mea.timestamp)
|
||||
fmt +=",timestamp"
|
||||
|
||||
#""" cmd = path + "/" + args.callback + " " + fmt + " " + params
|
||||
# print(cmd)
|
||||
# ret = os.system(cmd)./L
|
||||
# if (ret != 0):
|
||||
# measurements.appendleft(mea) #put the measurement back
|
||||
# print ("Data couln't be send to Callback, retrying...")
|
||||
# time.sleep(5) #wait before trying again
|
||||
# else: #data was sent
|
||||
# previousMeasurement=Measurement(mea.temperature,mea.humidity,mea.voltage,mea.calibratedHumidity,mea.battery,0) #using copy or deepcopy requires implementation in the class definition
|
||||
#"""
|
||||
except IndexError:
|
||||
#print("Keine Daten")
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print(traceback.format_exc())
|
||||
|
||||
sock = None #from ATC
|
||||
lastBLEPaketReceived = 0
|
||||
BLERestartCounter = 1
|
||||
def keepingLEScanRunning(): #LE-Scanning gets disabled sometimes, especially if you have a lot of BLE connections, this thread periodically enables BLE scanning again
|
||||
global BLERestartCounter
|
||||
while True:
|
||||
time.sleep(1)
|
||||
now = time.time()
|
||||
if now - lastBLEPaketReceived > args.watchdogtimer:
|
||||
print("Watchdog: Did not receive any BLE Paket within", int(now - lastBLEPaketReceived), "s. Restarting BLE scan. Count:", BLERestartCounter)
|
||||
disable_le_scan(sock)
|
||||
enable_le_scan(sock, filter_duplicates=False)
|
||||
BLERestartCounter += 1
|
||||
print("")
|
||||
time.sleep(5) #give some time to take effect
|
||||
|
||||
|
||||
def calibrateHumidity2Points(humidity, offset1, offset2, calpoint1, calpoint2):
|
||||
#offset1=args.offset1
|
||||
#offset2=args.offset2
|
||||
#p1y=args.calpoint1
|
||||
#p2y=args.calpoint2
|
||||
p1y=calpoint1
|
||||
p2y=calpoint2
|
||||
p1x=p1y - offset1
|
||||
p2x=p2y - offset2
|
||||
m = (p1y - p2y) * 1.0 / (p1x - p2x) # y=mx+b
|
||||
#b = (p1x * p2y - p2x * p1y) * 1.0 / (p1y - p2y)
|
||||
b = p2y - m * p2x #would be more efficient to do this calculations only once
|
||||
humidityCalibrated=m*humidity + b
|
||||
if (humidityCalibrated > 100 ): #with correct calibration this should not happen
|
||||
humidityCalibrated = 100
|
||||
elif (humidityCalibrated < 0):
|
||||
humidityCalibrated = 0
|
||||
humidityCalibrated=int(round(humidityCalibrated,0))
|
||||
return humidityCalibrated
|
||||
|
||||
|
||||
mode="round"
|
||||
class MyDelegate(btle.DefaultDelegate):
|
||||
def __init__(self, params):
|
||||
btle.DefaultDelegate.__init__(self)
|
||||
# ... initialise here
|
||||
|
||||
def handleNotification(self, cHandle, data):
|
||||
global measurements
|
||||
try:
|
||||
measurement = Measurement(0,0,0,0,0,0,0,0)
|
||||
if args.influxdb == 1:
|
||||
measurement.timestamp = int((time.time() // 10) * 10)
|
||||
else:
|
||||
measurement.timestamp = int(time.time())
|
||||
temp=int.from_bytes(data[0:2],byteorder='little',signed=True)/100
|
||||
#print("Temp received: " + str(temp))
|
||||
if args.round:
|
||||
#print("Temperatur unrounded: " + str(temp
|
||||
|
||||
if args.debounce:
|
||||
global mode
|
||||
temp*=10
|
||||
intpart = math.floor(temp)
|
||||
fracpart = round(temp - intpart,1)
|
||||
#print("Fracpart: " + str(fracpart))
|
||||
if fracpart >= 0.7:
|
||||
mode="ceil"
|
||||
elif fracpart <= 0.2: #either 0.8 and 0.3 or 0.7 and 0.2 for best even distribution
|
||||
mode="trunc"
|
||||
#print("Modus: " + mode)
|
||||
if mode=="trunc": #only a few times
|
||||
temp=math.trunc(temp)
|
||||
elif mode=="ceil":
|
||||
temp=math.ceil(temp)
|
||||
else:
|
||||
temp=round(temp,0)
|
||||
temp /=10.
|
||||
#print("Debounced temp: " + str(temp))
|
||||
else:
|
||||
temp=round(temp,1)
|
||||
humidity=int.from_bytes(data[2:3],byteorder='little')
|
||||
print("Temperature: " + str(temp))
|
||||
print("Humidity: " + str(humidity))
|
||||
voltage=int.from_bytes(data[3:5],byteorder='little') / 1000.
|
||||
print("Battery voltage:",voltage,"V")
|
||||
measurement.temperature = temp
|
||||
measurement.humidity = humidity
|
||||
measurement.voltage = voltage
|
||||
measurement.sensorname = args.name
|
||||
if args.battery:
|
||||
#measurement.battery = globalBatteryLevel
|
||||
batteryLevel = min(int(round((voltage - 2.1),2) * 100), 100) #3.1 or above --> 100% 2.1 --> 0 %
|
||||
measurement.battery = batteryLevel
|
||||
print("Battery level:",batteryLevel)
|
||||
|
||||
|
||||
if args.offset:
|
||||
humidityCalibrated = humidity + args.offset
|
||||
print("Calibrated humidity: " + str(humidityCalibrated))
|
||||
measurement.calibratedHumidity = humidityCalibrated
|
||||
|
||||
if args.TwoPointCalibration:
|
||||
humidityCalibrated= calibrateHumidity2Points(humidity,args.offset1,args.offset2, args.calpoint1, args.calpoint2)
|
||||
print("Calibrated humidity: " + str(humidityCalibrated))
|
||||
measurement.calibratedHumidity = humidityCalibrated
|
||||
|
||||
if(args.callback):
|
||||
measurements.append(measurement)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print("Fehler")
|
||||
print(e)
|
||||
print(traceback.format_exc())
|
||||
|
||||
# Initialisation -------
|
||||
|
||||
def connect():
|
||||
#print("Interface: " + str(args.interface))
|
||||
p = btle.Peripheral(adress,iface=args.interface)
|
||||
val=b'\x01\x00'
|
||||
p.writeCharacteristic(0x0038,val,True) #enable notifications of Temperature, Humidity and Battery voltage
|
||||
p.writeCharacteristic(0x0046,b'\xf4\x01\x00',True)
|
||||
p.withDelegate(MyDelegate("abc"))
|
||||
return p
|
||||
|
||||
# Main loop --------
|
||||
parser=argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("--device","-d", help="Set the device MAC-Address in format AA:BB:CC:DD:EE:FF",metavar='AA:BB:CC:DD:EE:FF')
|
||||
parser.add_argument("--battery","-b", help="Get estimated battery level", metavar='', type=int, nargs='?', const=1)
|
||||
parser.add_argument("--count","-c", help="Read/Receive N measurements and then exit script", metavar='N', type=int)
|
||||
parser.add_argument("--interface","-i", help="Specifiy the interface number to use, e.g. 1 for hci1", metavar='N', type=int, default=0)
|
||||
parser.add_argument("--unreachable-count","-urc", help="Exit after N unsuccessful connection tries", metavar='N', type=int, default=0)
|
||||
|
||||
|
||||
rounding = parser.add_argument_group("Rounding and debouncing")
|
||||
rounding.add_argument("--round","-r", help="Round temperature to one decimal place",action='store_true')
|
||||
rounding.add_argument("--debounce","-deb", help="Enable this option to get more stable temperature values, requires -r option",action='store_true')
|
||||
|
||||
offsetgroup = parser.add_argument_group("Offset calibration mode")
|
||||
offsetgroup.add_argument("--offset","-o", help="Enter an offset to the reported humidity value",type=int)
|
||||
|
||||
complexCalibrationGroup=parser.add_argument_group("2 Point Calibration")
|
||||
complexCalibrationGroup.add_argument("--TwoPointCalibration","-2p", help="Use complex calibration mode. All arguments below are required",action='store_true')
|
||||
complexCalibrationGroup.add_argument("--calpoint1","-p1", help="Enter the first calibration point",type=int)
|
||||
complexCalibrationGroup.add_argument("--offset1","-o1", help="Enter the offset for the first calibration point",type=int)
|
||||
complexCalibrationGroup.add_argument("--calpoint2","-p2", help="Enter the second calibration point",type=int)
|
||||
complexCalibrationGroup.add_argument("--offset2","-o2", help="Enter the offset for the second calibration point",type=int)
|
||||
|
||||
callbackgroup = parser.add_argument_group("Callback related arguments")
|
||||
callbackgroup.add_argument("--callback","-call", help="Pass the path to a program/script that will be called on each new measurement")
|
||||
callbackgroup.add_argument("--name","-n", help="Give this sensor a name reported to the callback script")
|
||||
callbackgroup.add_argument("--skipidentical","-skip", help="N consecutive identical measurements won't be reported to callbackfunction",metavar='N', type=int, default=0)
|
||||
callbackgroup.add_argument("--influxdb","-infl", help="Optimize for writing data to influxdb,1 timestamp optimization, 2 integer optimization",metavar='N', type=int, default=0)
|
||||
|
||||
atcgroup = parser.add_argument_group("ATC mode related arguments")
|
||||
atcgroup.add_argument("--atc","-a", help="Read the data of devices with custom ATC firmware flashed",action='store_true')
|
||||
atcgroup.add_argument("--watchdogtimer","-wdt",metavar='X', type=int, help="Re-enable scanning after not receiving any BLE packet after X seconds")
|
||||
atcgroup.add_argument("--devicelistfile","-df",help="Specify a device list file giving further details to devices")
|
||||
atcgroup.add_argument("--onlydevicelist","-odl", help="Only read devices which are in the device list file",action='store_true')
|
||||
atcgroup.add_argument("--rssi","-rs", help="Report RSSI via callback",action='store_true')
|
||||
|
||||
|
||||
args=parser.parse_args()
|
||||
if args.device:
|
||||
if re.match("[0-9a-fA-F]{2}([:]?)[0-9a-fA-F]{2}(\\1[0-9a-fA-F]{2}){4}$",args.device):
|
||||
adress=args.device
|
||||
else:
|
||||
print("Please specify device MAC-Address in format AA:BB:CC:DD:EE:FF")
|
||||
os._exit(1)
|
||||
elif not args.atc:
|
||||
parser.print_help()
|
||||
os._exit(1)
|
||||
|
||||
if args.TwoPointCalibration:
|
||||
if(not(args.calpoint1 and args.offset1 and args.calpoint2 and args.offset2)):
|
||||
print("In 2 Point calibration you have to enter 4 points")
|
||||
os._exit(1)
|
||||
elif(args.offset):
|
||||
print("Offset calibration and 2 Point calibration can't be used together")
|
||||
os._exit(1)
|
||||
if not args.name:
|
||||
args.name = args.device
|
||||
|
||||
if args.callback:
|
||||
dataThread = threading.Thread(target=thread_SendingData)
|
||||
dataThread.start()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
if args.device:
|
||||
|
||||
p=btle.Peripheral()
|
||||
cnt=0
|
||||
|
||||
|
||||
connected=False
|
||||
#logging.basicConfig(level=logging.DEBUG)
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
logging.debug("Debug: Starting script...")
|
||||
pid=os.getpid()
|
||||
bluepypid=None
|
||||
unconnectedTime=None
|
||||
connectionLostCounter=0
|
||||
|
||||
watchdogThread = threading.Thread(target=watchDog_Thread)
|
||||
watchdogThread.start()
|
||||
logging.debug("watchdogThread started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
if not connected:
|
||||
#Bluepy sometimes hangs and makes it even impossible to connect with gatttool as long it is running
|
||||
#on every new connection a new bluepy-helper is called
|
||||
#we now make sure that the old one is really terminated. Even if it hangs a simple kill signal was sufficient to terminate it
|
||||
# if bluepypid is not None:
|
||||
# os.system("kill " + bluepypid)
|
||||
# print("Killed possibly remaining bluepy-helper")
|
||||
# else:
|
||||
# print("bluepy-helper couldn't be determined, killing not allowed")
|
||||
|
||||
print("Trying to connect to " + adress)
|
||||
p=connect()
|
||||
# logging.debug("Own PID: " + str(pid))
|
||||
# pstree=os.popen("pstree -p " + str(pid)).read() #we want to kill only bluepy from our own process tree, because other python scripts have there own bluepy-helper process
|
||||
# logging.debug("PSTree: " + pstree)
|
||||
# try:
|
||||
# bluepypid=re.findall(r'bluepy-helper\((.*)\)',pstree)[0] #Store the bluepypid, to kill it later
|
||||
# except IndexError: #Should not happen since we're now connected
|
||||
# logging.debug("Couldn't find pid of bluepy-helper")
|
||||
connected=True
|
||||
unconnectedTime=None
|
||||
|
||||
# if args.battery:
|
||||
# if(cnt % args.battery == 0):
|
||||
# print("Warning the battery option is deprecated, Aqara device always reports 99 % battery")
|
||||
# batt=p.readCharacteristic(0x001b)
|
||||
# batt=int.from_bytes(batt,byteorder="little")
|
||||
# print("Battery-Level: " + str(batt))
|
||||
# globalBatteryLevel = batt
|
||||
|
||||
|
||||
if p.waitForNotifications(2000):
|
||||
# handleNotification() was called
|
||||
|
||||
cnt += 1
|
||||
if args.count is not None and cnt >= args.count:
|
||||
print(str(args.count) + " measurements collected. Exiting in a moment.")
|
||||
p.disconnect()
|
||||
time.sleep(5)
|
||||
#It seems that sometimes bluepy-helper remains and thus prevents a reconnection, so we try killing our own bluepy-helper
|
||||
pstree=os.popen("pstree -p " + str(pid)).read() #we want to kill only bluepy from our own process tree, because other python scripts have there own bluepy-helper process
|
||||
bluepypid=0
|
||||
try:
|
||||
bluepypid=re.findall(r'bluepy-helper\((.*)\)',pstree)[0] #Store the bluepypid, to kill it later
|
||||
except IndexError: #Should normally occur because we're disconnected
|
||||
logging.debug("Couldn't find pid of bluepy-helper")
|
||||
if bluepypid != 0:
|
||||
os.system("kill " + bluepypid)
|
||||
logging.debug("Killed bluepy with pid: " + str(bluepypid))
|
||||
os._exit(0)
|
||||
print("")
|
||||
continue
|
||||
except Exception as e:
|
||||
print("Connection lost")
|
||||
connectionLostCounter +=1
|
||||
if connected is True: #First connection abort after connected
|
||||
unconnectedTime=int(time.time())
|
||||
connected=False
|
||||
if args.unreachable_count != 0 and connectionLostCounter >= args.unreachable_count:
|
||||
print("Maximum numbers of unsuccessful connections reaches, exiting")
|
||||
os._exit(0)
|
||||
time.sleep(1)
|
||||
logging.debug(e)
|
||||
logging.debug(traceback.format_exc())
|
||||
|
||||
print ("Waiting...")
|
||||
# Perhaps do something else here
|
||||
|
||||
elif args.atc:
|
||||
print("Script started in ATC Mode")
|
||||
print("----------------------------")
|
||||
print("In this mode all devices within reach are read out, unless a namefile and --namefileonlydevices is specified.")
|
||||
print("Also --name Argument is ignored, if you require names, please use --namefile.")
|
||||
print("In this mode rounding and debouncing are not available, since ATC firmware sends out only one decimal place.")
|
||||
print("ATC mode usually requires root rights. If you want to use it with normal user rights, \nplease execute \"sudo setcap cap_net_raw,cap_net_admin+eip $(eval readlink -f `which python3`)\"")
|
||||
print("You have to redo this step if you upgrade your python version.")
|
||||
print("----------------------------")
|
||||
|
||||
import sys
|
||||
import bluetooth._bluetooth as bluez
|
||||
|
||||
from bluetooth_utils import (toggle_device,
|
||||
enable_le_scan, parse_le_advertising_events,
|
||||
disable_le_scan, raw_packet_to_str)
|
||||
mqttserver = "192.168.0.114"
|
||||
mqttclient = mqtt.Client("MiTemperature2")
|
||||
|
||||
advCounter=dict()
|
||||
sensors = dict()
|
||||
if args.devicelistfile:
|
||||
import configparser
|
||||
if not os.path.exists(args.devicelistfile):
|
||||
print ("Error specified device list file '",args.devicelistfile,"' not found")
|
||||
os._exit(1)
|
||||
sensors = configparser.ConfigParser()
|
||||
sensors.read(args.devicelistfile)
|
||||
|
||||
if args.onlydevicelist and not args.devicelistfile:
|
||||
print("Error: --onlydevicelist requires --devicelistfile <devicelistfile>")
|
||||
os._exit(1)
|
||||
|
||||
dev_id = args.interface # the bluetooth device is hci0
|
||||
toggle_device(dev_id, True)
|
||||
|
||||
try:
|
||||
sock = bluez.hci_open_dev(dev_id)
|
||||
except:
|
||||
print("Cannot open bluetooth device %i" % dev_id)
|
||||
raise
|
||||
|
||||
enable_le_scan(sock, filter_duplicates=False)
|
||||
|
||||
try:
|
||||
prev_data = None
|
||||
|
||||
def le_advertise_packet_handler(mac, adv_type, data, rssi):
|
||||
global lastBLEPaketReceived
|
||||
if args.watchdogtimer:
|
||||
lastBLEPaketReceived = time.time()
|
||||
lastBLEPaketReceived = time.time()
|
||||
#print("reveived BLE packet")
|
||||
data_str = raw_packet_to_str(data)
|
||||
ATCPaketMAC = data_str[10:22].upper()
|
||||
macStr = mac.replace(":","").upper()
|
||||
atcIdentifier = data_str[6:10].upper()
|
||||
if(atcIdentifier == "1A18" and ATCPaketMAC == macStr) and not args.onlydevicelist or (atcIdentifier == "1A18" and mac in sensors): #only Data from ATC devices, double checked
|
||||
advNumber = data_str[-2:]
|
||||
if macStr in advCounter:
|
||||
lastAdvNumber = advCounter[macStr]
|
||||
else:
|
||||
lastAdvNumber = None
|
||||
if lastAdvNumber == None or lastAdvNumber != advNumber:
|
||||
advCounter[macStr] = advNumber
|
||||
print("BLE packet: %s %02x %s %d" % (mac, adv_type, data_str, rssi))
|
||||
#print("AdvNumber: ", advNumber)
|
||||
#temp = data_str[22:26].encode('utf-8')
|
||||
#temperature = int.from_bytes(bytearray.fromhex(data_str[22:26]),byteorder='big') / 10.
|
||||
global measurements
|
||||
measurement = Measurement(0,0,0,0,0,0,0,0)
|
||||
if args.influxdb == 1:
|
||||
measurement.timestamp = int((time.time() // 10) * 10)
|
||||
else:
|
||||
measurement.timestamp = int(time.time())
|
||||
|
||||
|
||||
#temperature = int(data_str[22:26],16) / 10.
|
||||
temperature = int.from_bytes(bytearray.fromhex(data_str[22:26]),byteorder='big',signed=True) / 10.
|
||||
print("Temperature: ", temperature)
|
||||
humidity = int(data_str[26:28], 16)
|
||||
print("Humidity: ", humidity)
|
||||
batteryVoltage = int(data_str[30:34], 16) / 1000
|
||||
print ("Battery voltage:", batteryVoltage,"V")
|
||||
print ("RSSI:", rssi, "dBm")
|
||||
|
||||
if args.battery:
|
||||
batteryPercent = int(data_str[28:30], 16)
|
||||
print ("Battery:", batteryPercent,"%")
|
||||
measurement.battery = batteryPercent
|
||||
measurement.humidity = humidity
|
||||
measurement.temperature = temperature
|
||||
measurement.voltage = batteryVoltage
|
||||
measurement.rssi = rssi
|
||||
|
||||
if mac in sensors:
|
||||
try:
|
||||
measurement.sensorname = sensors[mac]["sensorname"]
|
||||
except:
|
||||
measurement.sensorname = mac
|
||||
if "offset1" in sensors[mac] and "offset2" in sensors[mac] and "calpoint1" in sensors[mac] and "calpoint2" in sensors[mac]:
|
||||
measurement.humidity = calibrateHumidity2Points(humidity,int(sensors[mac]["offset1"]),int(sensors[mac]["offset2"]),int(sensors[mac]["calpoint1"]),int(sensors[mac]["calpoint2"]))
|
||||
print ("Humidity calibrated (2 points calibration): ", measurement.humidity)
|
||||
elif "humidityOffset" in sensors[mac]:
|
||||
measurement.humidity = humidity + int(sensors[mac]["humidityOffset"])
|
||||
print ("Humidity calibrated (offset calibration): ", measurement.humidity)
|
||||
else:
|
||||
measurement.sensorname = mac
|
||||
|
||||
print ("MQTT publishing")
|
||||
mqttclient.connect(mqttserver)
|
||||
mqttclient.publish(("MiTemperature2/%s/temp" % measurement.sensorname), measurement.temperature)
|
||||
mqttclient.publish(("MiTemperature2/%s/humidity" % measurement.sensorname), measurement.humidity)
|
||||
mqttclient.publish(("MiTemperature2/%s/batterylevel" % measurement.sensorname), measurement.battery)
|
||||
print ("MQTT done")
|
||||
|
||||
|
||||
if(args.callback):
|
||||
measurements.append(measurement)
|
||||
#print("Length:", len(measurements))
|
||||
print("")
|
||||
|
||||
if args.watchdogtimer:
|
||||
keepingLEScanRunningThread = threading.Thread(target=keepingLEScanRunning)
|
||||
keepingLEScanRunningThread.start()
|
||||
logging.debug("keepingLEScanRunningThread started")
|
||||
|
||||
|
||||
|
||||
# Blocking call (the given handler will be called each time a new LE
|
||||
# advertisement packet is detected)
|
||||
parse_le_advertising_events(sock,
|
||||
handler=le_advertise_packet_handler,
|
||||
debug=False)
|
||||
except KeyboardInterrupt:
|
||||
disable_le_scan(sock)
|
||||
549
ROADMAP.md
549
ROADMAP.md
@@ -102,138 +102,70 @@ Using relative paths for portability across systems:
|
||||
|
||||
## Migration Phases
|
||||
|
||||
### Phase 1: Preparation & Cleanup ✓ TODO
|
||||
### Phase 1: Preparation & Cleanup ✅ DONE (2025-12-27)
|
||||
**Goal**: Reorganize repository without breaking existing functionality
|
||||
|
||||
**Notes**:
|
||||
- Created modern Python package structure with src/ layout
|
||||
- Converted INI sensor config to JSON format (sensors.json.example)
|
||||
- Environment-based configuration instead of hardcoded values
|
||||
- DHT11 sensor functionality removed as planned
|
||||
- Legacy scripts preserved in legacy/ folder
|
||||
|
||||
#### Tasks:
|
||||
1. Create new directory structure
|
||||
```bash
|
||||
mkdir -p src/sensorpajen
|
||||
mkdir -p config
|
||||
mkdir -p legacy
|
||||
mkdir -p systemd
|
||||
mkdir -p debian
|
||||
```
|
||||
|
||||
2. Create pyproject.toml with dependencies:
|
||||
- bluepy
|
||||
- paho-mqtt
|
||||
|
||||
3. Remove DHT11 functionality:
|
||||
- Delete temperatur_koksfonstret.py
|
||||
- Remove DHT11 cron job from documentation
|
||||
- Update README.md
|
||||
|
||||
4. Move legacy scripts to legacy/ folder:
|
||||
- LYWSD03MMC.py
|
||||
- sendToMQTT.sh
|
||||
- startup.sh
|
||||
- sensorer.sh
|
||||
- sensorer.ini
|
||||
- bluetooth_utils.py
|
||||
|
||||
5. Verify existing system still works with legacy scripts
|
||||
- ✅ Create new directory structure
|
||||
- ✅ Create pyproject.toml with dependencies
|
||||
- ✅ Remove DHT11 functionality
|
||||
- ✅ Move legacy scripts to legacy/ folder
|
||||
- ✅ Create config file templates (sensors.json.example, sensorpajen.env.example)
|
||||
- ✅ Preserve requirements.txt for backward compatibility
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Python Package Structure ✓ TODO
|
||||
### Phase 2: Python Package Structure ✅ DONE (2025-12-27)
|
||||
**Goal**: Create modern Python package with proper entry point
|
||||
|
||||
**Notes**:
|
||||
- Used src/ layout for better packaging practices
|
||||
- Direct Python MQTT integration (no shell script callbacks)
|
||||
- ATC firmware BLE advertisement reading (passive scanning)
|
||||
- Watchdog thread for BLE connection recovery
|
||||
- Clean separation of concerns (config, MQTT, sensors, main)
|
||||
|
||||
#### Tasks:
|
||||
1. Create `src/sensorpajen/__init__.py`
|
||||
- Package initialization
|
||||
- Version information
|
||||
|
||||
2. Create `src/sensorpajen/config.py`
|
||||
- Environment variable loading
|
||||
- Configuration validation
|
||||
- Default values
|
||||
- Fail-fast on missing required config
|
||||
```python
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# MQTT Configuration from environment
|
||||
MQTT_HOST = os.environ.get("MQTT_HOST")
|
||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
MQTT_USER = os.environ.get("MQTT_USER")
|
||||
MQTT_PASSWORD = os.environ.get("MQTT_PASSWORD")
|
||||
MQTT_CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "sensorpajen")
|
||||
|
||||
# Validate required config
|
||||
if not MQTT_HOST:
|
||||
raise RuntimeError("MQTT_HOST environment variable must be set")
|
||||
(relative to project root)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
SENSOR_CONFIG_FILE = os.environ.get(
|
||||
"SENSOR_CONFIG_FILE",
|
||||
str(PROJECT_ROOT / "config
|
||||
str(Path.home() / ".config/sensorpajen/sensors.json")
|
||||
)
|
||||
|
||||
# Bluetooth settings
|
||||
WATCHDOG_TIMEOUT = int(os.environ.get("WATCHDOG_TIMEOUT", "5"))
|
||||
ENABLE_BATTERY = os.environ.get("ENABLE_BATTERY", "true").lower() == "true"
|
||||
```
|
||||
|
||||
3. Create `src/sensorpajen/utils.py`
|
||||
- Port bluetooth_utils.py functionality
|
||||
- Clean up and modernize
|
||||
|
||||
4. Create `src/sensorpajen/sensor_reader.py`
|
||||
- Extract sensor reading logic from LYWSD03MMC.py
|
||||
- Remove callback/shell script dependency
|
||||
- Direct Python MQTT integration
|
||||
|
||||
5. Create `src/sensorpajen/mqtt_publisher.py`
|
||||
- MQTT client setup and connection
|
||||
- Publishing logic (replacing sendToMQTT.sh)
|
||||
- Error handling and reconnection
|
||||
|
||||
6. Create `src/sensorpajen/main.py`
|
||||
- Entry point for the application
|
||||
- Signal handling (SIGTERM, SIGINT)
|
||||
- Logging setup (to stdout for journald)
|
||||
- Main loop
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from . import config
|
||||
from .sensor_reader import SensorReader
|
||||
from .mqtt_publisher import MQTTPublisher
|
||||
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
stream=sys.stdout
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Starting sensorpajen service")
|
||||
|
||||
# Setup signal handlers
|
||||
def signal_handler(sig, frame):
|
||||
logger.info("Received shutdown signal")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Main application logic here
|
||||
# ...
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
- ✅ Created src/sensorpajen/__init__.py with version info
|
||||
- ✅ Created src/sensorpajen/config.py
|
||||
- Environment variable loading with validation
|
||||
- SensorConfig class for JSON sensor mapping
|
||||
- Relative path resolution (PROJECT_ROOT)
|
||||
- Configuration validation and logging
|
||||
- ✅ Created src/sensorpajen/utils.py
|
||||
- Ported bluetooth_utils.py (MIT licensed, Colin GUYON)
|
||||
- BLE scanning and advertisement parsing
|
||||
- ✅ Created src/sensorpajen/mqtt_publisher.py
|
||||
- MQTTPublisher class with connection management
|
||||
- Direct publishing (replaces sendToMQTT.sh)
|
||||
- Automatic reconnection support
|
||||
- Battery data publishing (optional)
|
||||
- ✅ Created src/sensorpajen/sensor_reader.py
|
||||
- SensorReader class for BLE scanning
|
||||
- ATC packet parsing
|
||||
- Duplicate packet filtering
|
||||
- Watchdog for BLE recovery
|
||||
- Measurement dataclass
|
||||
- ✅ Created src/sensorpajen/main.py
|
||||
- Application entry point
|
||||
- Signal handling (SIGTERM, SIGINT)
|
||||
- Graceful shutdown
|
||||
- Logging to stdout for journald
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Configuration Migration ✓ TODO
|
||||
### Phase 3: Configuration Migration ✅ DONE (2025-12-27)
|
||||
**Goal**: Replace .ini file with JSON and environment variables
|
||||
|
||||
**Notes**: Templates created in Phase 1, successfully tested on Raspberry Pi
|
||||
|
||||
#### Tasks:
|
||||
1. Create sensor mapping converter script
|
||||
- Read sensorer.ini
|
||||
@@ -304,9 +236,11 @@ config/sensorpajen.env
|
||||
debian/files
|
||||
debian/*.log
|
||||
debian/*.substvars
|
||||
### Phase 4: Virtual Environment & Dependencies ✓ TODO
|
||||
### Phase 4: Virtual Environment & Dependencies ✅ DONE (2025-12-27)
|
||||
**Goal**: Set up isolated Python environment
|
||||
|
||||
**Notes**: Tested on Raspberry Pi, paho-mqtt v2.x compatibility fixed
|
||||
|
||||
#### Tasks:
|
||||
1. Create virtual environment:
|
||||
```bash
|
||||
@@ -332,286 +266,163 @@ config/sensorpajen.env
|
||||
|
||||
4. Document virtual environment usage in README
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Bluetooth Permissions ✓ TODO
|
||||
---✅ DONE (2025-12-27)
|
||||
**Goal**: Allow non-root user to access Bluetooth
|
||||
|
||||
**Notes**: Tested on Raspberry Pi with setcap on actual Python binary
|
||||
### Phase 5: Bluetooth Permissions ✅ DONE (2025-12-27)
|
||||
**Goal**: Allow non-root user to access Bluetooth
|
||||
|
||||
**Notes**: Tested on Raspberry Pi with setcap on actual Python binary
|
||||
|
||||
#### Tasks:
|
||||
1. Add user to bluetooth group:
|
||||
```bash
|
||||
sudo usermod -a -G bluetooth fredrik
|
||||
```
|
||||
|
||||
2. Set capabilities on Python interpreter (if needed):
|
||||
```bash
|
||||
sudo setcap 'cap_net_raw,cap_net_admin+eip' .venv/bin/python3
|
||||
```%h/sensorpajen
|
||||
EnvironmentFile=%h/sensorpajen/config/sensorpajen.env
|
||||
ExecStart=%h/sensorpajen/.venv/bin/python -m sensorpajen.main
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Bluetooth capabilities
|
||||
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=sensorpajen
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
Note: `%h` expands to the user's home directorycription=Sensorpajen - Bluetooth Temperature Sensor Monitor
|
||||
After=network.target bluetooth.target
|
||||
Wants=bluetooth.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/fredrik/dev/sensorpajen
|
||||
EnvironmentFile=/home/fredrik/.config/sensorpajen/sensorpajen.env
|
||||
ExecStart=/home/fredrik/dev/sensorpajen/.venv/bin/python -m sensorpajen.main
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Bluetooth capabilities
|
||||
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=sensorpajen
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
2. Install service (user service):
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user/
|
||||
cp systemd/sensorpajen.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
3. Enable lingering (service runs without login):
|
||||
```bash
|
||||
sudo loginctl enable-linger fredrik
|
||||
```
|
||||
|
||||
4. Document systemd commands in README
|
||||
- ✅ Bluetooth capabilities set with setcap
|
||||
- ✅ Documented in SETUP_ON_PI.md with correct readlink -f usage
|
||||
- ✅ Tested successfully on Raspberry Pi
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Testing & Validation ✓ TODO
|
||||
### Phase 6: Systemd Service Creation ✅ DONE (2025-12-27)
|
||||
**Goal**: Create and configure systemd user service
|
||||
|
||||
**Notes**:
|
||||
- User service for easier management (no sudo required)
|
||||
- Service ready for installation on Raspberry Pi
|
||||
- Comprehensive documentation provided
|
||||
- **Important discoveries**:
|
||||
- `AmbientCapabilities` does NOT work in user services (only system services)
|
||||
- Must use `setcap` on the Python binary instead
|
||||
- `NoNewPrivileges=true` prevents file capabilities from working - must be disabled
|
||||
- Capabilities must be set on actual binary, not symlinks: `setcap ... $(readlink -f python3)`
|
||||
|
||||
#### Tasks:
|
||||
- ✅ Created systemd/sensorpajen.service
|
||||
- ✅ Created systemd/README.md with full documentation
|
||||
- ✅ Service management and troubleshooting guides included
|
||||
- ✅ Tested and verified working on Raspberry Pi
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Testing & Validation ✅ DONE (2025-12-27)
|
||||
**Goal**: Verify new service works before removing legacy
|
||||
|
||||
**Notes**:
|
||||
- Service tested and running successfully
|
||||
- Legacy cron/tmux system stopped
|
||||
- All sensors reporting correctly via systemd service
|
||||
|
||||
#### Tasks:
|
||||
1. Stop legacy cron/tmux processes:
|
||||
```bash
|
||||
crontab -e # Comment out sensorpajen entries
|
||||
tmux kill-session -t sensorer
|
||||
```
|
||||
- ✅ Stopped legacy cron/tmux processes
|
||||
- ✅ Started new systemd service
|
||||
- ✅ Monitored logs - no errors
|
||||
- ✅ Verified all 8 sensors reporting
|
||||
- ✅ Confirmed MQTT publishing working
|
||||
- ✅ Tested service restart and auto-recovery
|
||||
|
||||
2. Start new service:
|
||||
```bash
|
||||
systemctl --user start sensorpajen
|
||||
```
|
||||
---
|
||||
|
||||
3. Monitor logs:
|
||||
```bash
|
||||
journalctl --user -u sensorpajen -f
|
||||
```APT Package Creation ✓ TODO
|
||||
### Phase 8: APT Package Creation ✅ DONE (2025-12-27)
|
||||
**Goal**: Create Debian package for easy installation on Raspberry Pi
|
||||
|
||||
#### Tasks:
|
||||
1. Create debian/ directory structure:
|
||||
```bash
|
||||
mkdir -p debian
|
||||
```
|
||||
**Notes**:
|
||||
- Complete debian/ directory structure created
|
||||
- System-wide installation to /opt/sensorpajen
|
||||
- Configuration in /etc/sensorpajen
|
||||
- Dedicated sensorpajen system user
|
||||
- Automatic venv creation in postinst
|
||||
- Bluetooth capabilities set automatically
|
||||
- Config preserved on remove/purge for safety
|
||||
- Dual-mode support: system installation and development
|
||||
- config.py auto-detects installation type
|
||||
|
||||
2. Create `debian/control`:
|
||||
``APT package installation instructions
|
||||
- Development installation instructions
|
||||
- Configuration guide (relative paths)
|
||||
- Service management commands
|
||||
- Troubleshooting section
|
||||
- Remove DHT11 references
|
||||
- Remove pirate_audio references
|
||||
#### Files Created:
|
||||
- ✅ debian/control - Package metadata and dependencies
|
||||
- ✅ debian/compat - Debhelper compatibility level
|
||||
- ✅ debian/changelog - Package version history
|
||||
- ✅ debian/rules - Build instructions
|
||||
- ✅ debian/install - File installation mappings
|
||||
- ✅ debian/postinst - Post-installation script (user, venv, setcap)
|
||||
- ✅ debian/prerm - Pre-removal script (stop service)
|
||||
- ✅ debian/postrm - Post-removal script (cleanup)
|
||||
- ✅ debian/sensorpajen.service - System-wide systemd unit
|
||||
|
||||
3. Create INSTALL.md:
|
||||
- APT package installation steps
|
||||
- Manual installation steps
|
||||
- Configuration examples
|
||||
- First-time setup guide
|
||||
- Raspberry Pi specific instructionsds}, ${misc:Depends},
|
||||
python3-bluepy,
|
||||
python3-paho-mqtt,
|
||||
bluetooth,
|
||||
bluez
|
||||
Description: Bluetooth temperature sensor monitor
|
||||
Monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors
|
||||
and publishes data to MQTT broker.
|
||||
```
|
||||
#### Code Updates:
|
||||
- ✅ Updated src/sensorpajen/config.py to detect system installation
|
||||
- Checks for /opt/sensorpajen existence
|
||||
- Uses /etc/sensorpajen for config in system mode
|
||||
- Falls back to PROJECT_ROOT/config for development
|
||||
- ✅ Updated scripts/approve-sensors.sh for dual-mode operation
|
||||
- Detects system vs development installation
|
||||
- Uses correct venv and config paths
|
||||
- ✅ Created scripts/verify-deb.sh - Automated build and verification
|
||||
|
||||
3. Create `debian/rules`:
|
||||
```makefile
|
||||
#!/usr/bin/make -f
|
||||
|
||||
%:
|
||||
dh $@ --with python3 --buildsystem=pybuild
|
||||
|
||||
override_dh_auto_install:
|
||||
pytOption 1: APT Package (Recommended for Raspberry Pi)
|
||||
|
||||
1. Download and install the .deb package:
|
||||
```bash
|
||||
sudo dpkg -i sensorpajen_1.0.0_all.deb
|
||||
sudo apt-get install -f # Fix any dependencies
|
||||
```
|
||||
|
||||
2. Configure:
|
||||
```bash
|
||||
mkdir -p ~/sensorpajen/config
|
||||
cp /usr/share/doc/sensorpajen/examples/sensorpajen.env.example ~/sensorpajen/config/sensorpajen.env
|
||||
cp /usr/share/doc/sensorpajen/examples/sensors.json.example ~/sensorpajen/config/sensors.json
|
||||
# Edit both files
|
||||
nano ~/sensorpajen/config/sensorpajen.env
|
||||
nano ~/sensorpajen/config/sensors.json
|
||||
chmod 600 ~/sensorpajen/config/sensorpajen.env
|
||||
```
|
||||
|
||||
3. Enable and start service:
|
||||
```bash
|
||||
systemctl --user enable sensorpajen
|
||||
systemctl --user start sensorpajen
|
||||
```
|
||||
|
||||
### Option 2: Development Installation
|
||||
|
||||
1. Clone Repository
|
||||
```bash
|
||||
git clone <repo> ~/sensorpajen
|
||||
cd ~/sensorpajen
|
||||
```
|
||||
|
||||
2. Create Virtual Environment
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
#### Package Details:
|
||||
- Package name: sensorpajen
|
||||
- Version: 2.0.0-dev
|
||||
- Architecture: all
|
||||
- System paths:
|
||||
- Application: /opt/sensorpajen/
|
||||
- Configuration: /etc/sensorpajen/
|
||||
- Service file: /etc/systemd/system/sensorpajen.service
|
||||
- Examples: /usr/share/doc/sensorpajen/examples/
|
||||
- Runs as dedicated sensorpajen user (system account)
|
||||
- Auto-enables service but waits for configuration before starting
|
||||
|
||||
### Relative Paths (For Portability)
|
||||
- **Project root**: `~/sensorpajen/` (or wherever you clone/install)
|
||||
- **Application config**: `~/sensorpajen/config/`
|
||||
- **Environment file**: `~/sensorpajen/config/sensorpajen.env` (0600)
|
||||
- **Sensor mapping**: `~/sensorpajen/config/sensors.json` (0644)
|
||||
- **Service file**: `~/.config/systemd/user/sensorpajen.service`
|
||||
#### Build and Test:
|
||||
```bash
|
||||
# Build package
|
||||
./scripts/verify-deb.sh
|
||||
|
||||
### Advantages of Relative Paths
|
||||
- Works on any system (development, production, multiple Raspberry Pis)
|
||||
- Easy to backup/restore entire directory
|
||||
- No hardcoded paths in code
|
||||
- Simple to deploy via git pull or package installation
|
||||
- User service runs without sudo
|
||||
# Or manually:
|
||||
dpkg-buildpackage -us -uc -b
|
||||
lintian ../sensorpajen_*.deb
|
||||
|
||||
### APT Package Installation
|
||||
When installed via .deb package:
|
||||
- **Python package**: `/usr/lib/python3/dist-packages/sensorpajen/`
|
||||
- **Service file**: `/lib/systemd/user/sensorpajen.service`
|
||||
- **Config templates**: `/usr/share/doc/sensorpajen/examples/`
|
||||
- **User config**: `~/sensorpajen/config/` (created by user)sensorpajen
|
||||
```
|
||||
|
||||
5. Verify
|
||||
```bash
|
||||
systemctl --user status sensorpajen
|
||||
journalctl --user -u sensorpajen -f
|
||||
```uetooth access
|
||||
if [ "$1" = "configure" ]; then
|
||||
PYTHON_PATH=$(readlink -f /usr/bin/python3)
|
||||
setcap 'cap_net_raw,cap_net_admin+eip' "$PYTHON_PATH" || true
|
||||
fi
|
||||
|
||||
#DEBHELPER#
|
||||
```
|
||||
# Install on Raspberry Pi:
|
||||
scp ../sensorpajen_*.deb pi@raspberrypi:~/
|
||||
ssh pi@raspberrypi
|
||||
sudo apt install ./sensorpajen_*.deb
|
||||
|
||||
7. Create `debian/README.Debian`:
|
||||
- Installation instructions
|
||||
- Configuration guide
|
||||
- Service management
|
||||
# Configure:
|
||||
sudo nano /etc/sensorpajen/sensorpajen.env
|
||||
sudo nano /etc/sensorpajen/sensors.json
|
||||
|
||||
8. Build the package:
|
||||
```bash
|
||||
dpkg-buildpackage -us -uc -b
|
||||
```
|
||||
|
||||
9. Test installation on Raspberry Pi:
|
||||
```bash
|
||||
sudo dpkg -i ../sensorpajen_1.0.0_all.deb
|
||||
sudo apt-get install -f # Fix dependencies if needed
|
||||
```
|
||||
|
||||
10. Create installation documentation:
|
||||
- Package installation instructions
|
||||
- Configuration setup after installation
|
||||
- Service enablement
|
||||
# Start:
|
||||
sudo systemctl start sensorpajen
|
||||
sudo journalctl -u sensorpajen -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 9:
|
||||
|
||||
4. Verify MQTT messages:
|
||||
```bash
|
||||
mosquitto_sub -h 192.168.0.114 -u hasse -P casablanca -t "MiTemperature2/#" -v
|
||||
```
|
||||
|
||||
5. Test service restart:
|
||||
```bash
|
||||
systemctl --user restart sensorpajen
|
||||
```
|
||||
|
||||
6. Test crash recovery (kill process, verify auto-restart)
|
||||
|
||||
7. Test boot behavior:
|
||||
```bash
|
||||
systemctl --user enable sensorpajen
|
||||
sudo reboot
|
||||
# After reboot, verify service is running
|
||||
systemctl --user status sensorpajen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Cleanup & Document
|
||||
- [ ] Publish APT package to personal repository
|
||||
- [ ] Create automated build pipeline for .deb packages
|
||||
- [ ] Add support for multiple MQTT brokers
|
||||
- [ ] Implement configuration validation toolation ✓ TODO
|
||||
### Phase 9: Cleanup & Documentation ✅ DONE (2025-12-27)
|
||||
**Goal**: Remove legacy code and finalize documentation
|
||||
|
||||
**Notes**:
|
||||
- Legacy cron/tmux scripts removed
|
||||
- Documentation focused on practical usage
|
||||
- INSTALL.md created for sysadmins
|
||||
|
||||
#### Tasks:
|
||||
1. Once new service is stable (run for 1-2 weeks):
|
||||
- Delete legacy/ folder
|
||||
- Remove cron jobs completely
|
||||
- Remove tmux session references
|
||||
- ✅ Deleted legacy/ folder (old cron/tmux scripts)
|
||||
- ✅ Created INSTALL.md with concise installation guide
|
||||
- ✅ Updated README.md troubleshooting section
|
||||
- ✅ Documentation assumes sysadmin familiarity
|
||||
|
||||
2. Update README.md:
|
||||
- Installation instructions
|
||||
- Configuration guide
|
||||
- Service management commands
|
||||
- Troubleshooting section
|
||||
- Remove DHT11 references
|
||||
- Remove pirate_audio references
|
||||
---
|
||||
|
||||
3. Create INSTALL.md:
|
||||
- Fresh installation steps
|
||||
- Configuration examples
|
||||
- First-time setup guide
|
||||
## Migration Complete! 🎉
|
||||
|
||||
4. Document in README:
|
||||
All phases completed. The system has been successfully migrated from a legacy cron/tmux-based system to a modern systemd service with:
|
||||
|
||||
- ✅ Python package structure
|
||||
- ✅ Environment-based configuration (no .ini files)
|
||||
- ✅ Systemd user service with auto-restart
|
||||
- ✅ Automatic sensor discovery with approval workflow
|
||||
- ✅ Configuration auto-reload (no restart needed)
|
||||
- ✅ ntfy notifications for new sensors
|
||||
- ✅ Comprehensive documentation
|
||||
|
||||
**Version**: 2.0.0-dev
|
||||
**Status**: Production-ready
|
||||
```markdown
|
||||
## Installation
|
||||
|
||||
|
||||
180
SETUP_ON_PI.md
Normal file
180
SETUP_ON_PI.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Setup on Raspberry Pi - Testing Guide
|
||||
|
||||
## Prerequisites
|
||||
- Raspberry Pi with Bluetooth support
|
||||
- Raspberry Pi OS (Debian-based)
|
||||
- Git repository access
|
||||
- MQTT broker accessible from the Pi
|
||||
|
||||
## Quick Setup Steps
|
||||
|
||||
### 1. Pull Latest Changes
|
||||
```bash
|
||||
cd ~/sensorpajen # or wherever your repo is
|
||||
git pull origin master
|
||||
```
|
||||
|
||||
### 2. Install System Dependencies
|
||||
```bash
|
||||
# Install Bluetooth and build tools
|
||||
sudo apt update
|
||||
sudo apt install -y bluetooth bluez libbluetooth-dev python3-dev python3-pip python3-venv
|
||||
|
||||
# Verify Bluetooth is working
|
||||
sudo systemctl status bluetooth
|
||||
```
|
||||
|
||||
### 3. Create Virtual Environment
|
||||
```bash
|
||||
cd ~/sensorpajen
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 4. Install Python Dependencies
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
pip install pybluez bluepy paho-mqtt
|
||||
|
||||
# Or install the package in development mode
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 5. Set Bluetooth Capabilities
|
||||
This allows Python to access Bluetooth without sudo:
|
||||
```bash
|
||||
# Set capabilities on the actual Python binary (not the symlink)
|
||||
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f .venv/bin/python3)
|
||||
|
||||
# Verify it was set correctly
|
||||
getcap $(readlink -f .venv/bin/python3)
|
||||
# Should show: cap_net_raw,cap_net_admin+eip
|
||||
```
|
||||
|
||||
### 6. Configure the Application
|
||||
```bash
|
||||
# Copy configuration templates
|
||||
cp config/sensorpajen.env.example config/sensorpajen.env
|
||||
cp config/sensors.json.example config/sensors.json
|
||||
|
||||
# Edit MQTT settings
|
||||
nano config/sensorpajen.env
|
||||
# Update MQTT_HOST, MQTT_USER, MQTT_PASSWORD
|
||||
|
||||
# Verify/edit sensor list
|
||||
nano config/sensors.json
|
||||
# Should already have your 8 sensors from legacy config
|
||||
```
|
||||
|
||||
### 7. Test Run
|
||||
```bash
|
||||
# Make sure virtual environment is activated
|
||||
source .venv/bin/activate
|
||||
|
||||
# Load environment variables
|
||||
export $(cat config/sensorpajen.env | grep -v '^#' | xargs)
|
||||
|
||||
# Run the application
|
||||
python -m sensorpajen.main
|
||||
```
|
||||
|
||||
You should see:
|
||||
- Configuration being loaded
|
||||
- MQTT connection established
|
||||
- BLE scanning started
|
||||
- Sensor readings as they come in
|
||||
|
||||
Press Ctrl+C to stop.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bluetooth Permission Issues
|
||||
If you get permission errors:
|
||||
```bash
|
||||
# Check if capabilities are set
|
||||
getcap $(readlink -f .venv/bin/python3)
|
||||
|
||||
# If not set, run:
|
||||
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f .venv/bin/python3)
|
||||
|
||||
# Verify Bluetooth device is up
|
||||
sudo hciconfig hci0 up
|
||||
```
|
||||
|
||||
### MQTT Connection Issues
|
||||
```bash
|
||||
# Test MQTT connection
|
||||
mosquitto_sub -h 10.0.0.114 -u hasse -P casablanca -t "MiTemperature2/#" -v
|
||||
|
||||
# Check if broker is accessible
|
||||
ping 192.168.0.114 # or your MQTT broker IP
|
||||
```
|
||||
|
||||
### No Sensor Data
|
||||
```bash
|
||||
# Check if sensors are in range and broadcasting
|
||||
sudo hcitool lescan
|
||||
|
||||
# Check logs for specific errors
|
||||
python -m sensorpajen.main 2>&1 | tee test.log
|
||||
```
|
||||
|
||||
### BluePy Installation Issues
|
||||
If bluepy fails to install:
|
||||
```bash
|
||||
sudo apt install -y libglib2.0-dev
|
||||
pip install --no-cache-dir bluepy
|
||||
```
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
Copy from `config/sensorpajen.env.example` and modify:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
MQTT_HOST=192.168.0.114 # Your MQTT broker IP
|
||||
MQTT_USER=hasse # MQTT username
|
||||
MQTT_PASSWORD=casablanca # MQTT password
|
||||
|
||||
# Optional
|
||||
MQTT_PORT=1883 # Default MQTT port
|
||||
MQTT_CLIENT_ID=mibridge # Client identifier
|
||||
MQTT_TOPIC_PREFIX=MiTemperature2 # MQTT topic prefix
|
||||
|
||||
# Application settings
|
||||
SENSOR_CONFIG_FILE=config/sensors.json # Sensor config file
|
||||
WATCHDOG_TIMEOUT=5 # BLE watchdog timeout (seconds)
|
||||
ENABLE_BATTERY=true # Include battery data
|
||||
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
|
||||
```
|
||||
|
||||
## Viewing Logs
|
||||
```bash
|
||||
# While running in terminal, logs go to stdout
|
||||
python -m sensorpajen.main
|
||||
|
||||
# To save logs to file
|
||||
python -m sensorpajen.main 2>&1 | tee sensorpajen.log
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once testing is successful:
|
||||
1. Continue to Phase 4-6 in ROADMAP.md to set up systemd service
|
||||
2. Service will run automatically on boot
|
||||
3. Logs will be available via `journalctl --user -u sensorpajen`
|
||||
|
||||
## Returning to Development Machine
|
||||
|
||||
All changes can be committed on the Pi and pushed back:
|
||||
```bash
|
||||
# On Raspberry Pi
|
||||
git add -A
|
||||
git commit -m "Your changes"
|
||||
git push origin master
|
||||
|
||||
# On development machine
|
||||
git pull origin master
|
||||
```
|
||||
|
||||
The development workflow works seamlessly from either machine!
|
||||
762
TASKS.md
Normal file
762
TASKS.md
Normal file
@@ -0,0 +1,762 @@
|
||||
# Tasks
|
||||
|
||||
## Task: Debian Package Creation
|
||||
|
||||
**Status**: DONE (2025-12-27)
|
||||
**Priority**: Medium
|
||||
**Estimated Effort**: 4-6 hours
|
||||
**Actual Effort**: ~5 hours
|
||||
|
||||
### Implementation Summary
|
||||
|
||||
Successfully created a complete Debian package infrastructure for system-wide installation on Raspberry Pi and Debian-based systems. The package provides:
|
||||
|
||||
- **System-wide installation** to `/opt/sensorpajen/` with dedicated user
|
||||
- **Configuration management** via `/etc/sensorpajen/` (preserved on upgrades)
|
||||
- **Automatic setup** including Python venv, dependencies, and Bluetooth capabilities
|
||||
- **Dual-mode operation** supporting both system and development installations
|
||||
- **Build verification** with automated script
|
||||
|
||||
### Files Created
|
||||
|
||||
#### Debian Package Files (debian/)
|
||||
- `control` - Package metadata, dependencies, maintainer info
|
||||
- `compat` - Debhelper compatibility (v13)
|
||||
- `changelog` - Version history and release notes
|
||||
- `rules` - Build instructions (Makefile)
|
||||
- `install` - File installation mappings
|
||||
- `postinst` - Post-installation script (creates user, venv, sets capabilities)
|
||||
- `prerm` - Pre-removal script (stops service)
|
||||
- `postrm` - Post-removal script (cleanup, preserves config)
|
||||
- `sensorpajen.service` - System-wide systemd unit file
|
||||
|
||||
#### Updated Code
|
||||
- `src/sensorpajen/config.py` - Auto-detects system vs development installation
|
||||
- `scripts/approve-sensors.sh` - Supports both installation modes
|
||||
- `scripts/verify-deb.sh` - Automated build and verification script (NEW)
|
||||
|
||||
### Installation Paths
|
||||
|
||||
**System Installation (via .deb):**
|
||||
- Application: `/opt/sensorpajen/`
|
||||
- Python venv: `/opt/sensorpajen/venv/`
|
||||
- Configuration: `/etc/sensorpajen/`
|
||||
- Service: `/etc/systemd/system/sensorpajen.service`
|
||||
- Examples: `/usr/share/doc/sensorpajen/examples/`
|
||||
- User: `sensorpajen` (system account, no login)
|
||||
|
||||
**Development Installation:**
|
||||
- Application: `<project-root>/`
|
||||
- Python venv: `<project-root>/.venv/`
|
||||
- Configuration: `<project-root>/config/`
|
||||
- Service: `~/.config/systemd/user/sensorpajen.service`
|
||||
|
||||
### Key Features Implemented
|
||||
|
||||
✅ System-wide installation with dedicated user
|
||||
✅ Python venv created automatically in postinst
|
||||
✅ All dependencies installed from PyPI
|
||||
✅ Bluetooth capabilities set automatically (setcap)
|
||||
✅ Systemd service enabled but not started (waits for config)
|
||||
✅ Configuration preserved on upgrade/remove/purge
|
||||
✅ Example configs copied to /etc/sensorpajen on first install
|
||||
✅ Dual-mode code (auto-detects system vs dev)
|
||||
✅ Automated verification script
|
||||
✅ Full lintian compliance
|
||||
|
||||
### Build and Install
|
||||
|
||||
```bash
|
||||
# Verify and build
|
||||
./scripts/verify-deb.sh
|
||||
|
||||
# Or manually
|
||||
dpkg-buildpackage -us -uc -b
|
||||
lintian ../sensorpajen_*.deb
|
||||
|
||||
# Install on Raspberry Pi
|
||||
scp ../sensorpajen_*.deb pi@raspberrypi:~/
|
||||
ssh pi@raspberrypi
|
||||
sudo apt install ./sensorpajen_*.deb
|
||||
|
||||
# Configure
|
||||
sudo nano /etc/sensorpajen/sensorpajen.env
|
||||
sudo nano /etc/sensorpajen/sensors.json
|
||||
|
||||
# Start service
|
||||
sudo systemctl start sensorpajen
|
||||
sudo journalctl -u sensorpajen -f
|
||||
```
|
||||
|
||||
### Testing Results
|
||||
|
||||
✅ Package builds successfully with `dpkg-buildpackage`
|
||||
✅ Lintian passes without errors (warnings acceptable)
|
||||
✅ Files installed to correct locations
|
||||
✅ System user created automatically
|
||||
✅ Python venv created with all dependencies
|
||||
✅ Bluetooth capabilities set correctly
|
||||
✅ Service enabled but not started before config
|
||||
✅ Configuration preserved on upgrade/remove/purge
|
||||
✅ Service runs as sensorpajen user (not root)
|
||||
✅ Logs appear in `journalctl -u sensorpajen`
|
||||
✅ Dual-mode operation works correctly
|
||||
|
||||
### Overview
|
||||
Create a Debian `.deb` package for system-wide installation of sensorpajen on Raspberry Pi OS and other Debian-based systems. This enables easy distribution and installation via `apt`/`dpkg` instead of manual git clone + pip install.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **System-Wide Installation**
|
||||
- Install application to `/opt/sensorpajen/`
|
||||
- Create Python virtual environment in `/opt/sensorpajen/venv/`
|
||||
- Install systemd service file to `/etc/systemd/system/`
|
||||
- Place configuration in `/etc/sensorpajen/`
|
||||
- Put example configs in `/usr/share/doc/sensorpajen/examples/`
|
||||
|
||||
2. **Dedicated Service User**
|
||||
- Create `sensorpajen` system user if not exists
|
||||
- Service runs as `sensorpajen:sensorpajen`
|
||||
- User has no login shell, no home directory (system account)
|
||||
|
||||
3. **Automatic Service Configuration**
|
||||
- Auto-enable systemd service on installation
|
||||
- Configure Bluetooth capabilities (setcap) automatically
|
||||
- Service starts after installation if config exists
|
||||
|
||||
4. **Configuration Management**
|
||||
- Install example configs to `/usr/share/doc/sensorpajen/examples/`:
|
||||
- `sensorpajen.env.example`
|
||||
- `sensors.json.example`
|
||||
- `discovered_sensors.json.example`
|
||||
- Actual config expected in `/etc/sensorpajen/`:
|
||||
- `sensorpajen.env`
|
||||
- `sensors.json`
|
||||
- Do NOT overwrite existing config on upgrade
|
||||
- Preserve config on package removal
|
||||
- Keep config even on purge (user explicitly chooses)
|
||||
- Postinst should copy the examples into `/etc/sensorpajen/` only if they are missing, leaving any existing config untouched
|
||||
- Upgrades should refresh `/usr/share/doc/sensorpajen/examples/` with new defaults but never alter live configs under `/etc/sensorpajen/`
|
||||
|
||||
5. **Dependency Management**
|
||||
- Depend on system packages: `python3`, `python3-venv`, `python3-pip`, `bluetooth`, `bluez`
|
||||
- Create venv and install Python deps from PyPI in postinst script
|
||||
- Use `pyproject.toml` for Python dependency specification
|
||||
|
||||
6. **Package Metadata**
|
||||
- Package name: `sensorpajen`
|
||||
- Section: `misc`
|
||||
- Priority: `optional`
|
||||
- Architecture: `all`
|
||||
- Maintainer: Fredrik (fredrik@wahlberg.se)
|
||||
- Homepage: Repository URL
|
||||
- Description: "Raspberry Pi Bluetooth temperature sensor monitor"
|
||||
- Depends: System packages
|
||||
- Recommends: `mosquitto-clients` (optional)
|
||||
- **Version Source**: Extract version from `pyproject.toml` during build process.
|
||||
|
||||
7. **Files to Include**
|
||||
- All Python source code from `src/sensorpajen/`
|
||||
- Scripts from `scripts/` (approve-sensors.sh)
|
||||
- Systemd service file (system service, not user service)
|
||||
- Example configuration files
|
||||
- Documentation: `README.md`, `INSTALL.md`
|
||||
- License file
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] Package builds successfully with `dpkg-buildpackage -us -uc -b`
|
||||
- [ ] Can install on fresh Raspberry Pi OS with `sudo apt install ./sensorpajen_*.deb`
|
||||
- [ ] Service user `sensorpajen` created automatically
|
||||
- [ ] Python venv created in `/opt/sensorpajen/venv/` with all dependencies
|
||||
- [ ] Bluetooth capabilities set on Python executable
|
||||
- [ ] Systemd service enabled but not started (waits for config)
|
||||
- [ ] After copying examples to `/etc/sensorpajen/` and editing, service starts successfully
|
||||
- [ ] Service runs as `sensorpajen` user, not root
|
||||
- [ ] Logs appear in `journalctl -u sensorpajen`
|
||||
- [ ] Package upgrade preserves `/etc/sensorpajen/` config files
|
||||
- [ ] Package removal (`dpkg -r`) stops service but keeps config
|
||||
- [ ] Package purge (`dpkg -P`) keeps config (user explicitly deletes if wanted)
|
||||
- [ ] `lintian` passes with no errors (warnings acceptable)
|
||||
- [ ] Automated verification script exists that builds the `.deb` and runs `lintian`
|
||||
|
||||
### Implementation Details
|
||||
|
||||
#### 1. Create `debian/` Directory Structure
|
||||
|
||||
```
|
||||
debian/
|
||||
├── control # Package metadata and dependencies
|
||||
├── rules # Build instructions (Makefile)
|
||||
├── install # Files to install and destinations
|
||||
├── postinst # Post-installation script
|
||||
├── prerm # Pre-removal script
|
||||
├── postrm # Post-removal script
|
||||
├── changelog # Required for native build (minimal entry)
|
||||
└── sensorpajen.service # Systemd service file (system-wide)
|
||||
```
|
||||
|
||||
#### 2. `debian/control` File
|
||||
|
||||
```
|
||||
Source: sensorpajen
|
||||
Section: misc
|
||||
Priority: optional
|
||||
Maintainer: Fredrik <fredrik@wahlberg.se>
|
||||
Build-Depends: debhelper-compat (= 13)
|
||||
Standards-Version: 4.5.0
|
||||
Homepage: https://git.example.com/fredrik/sensorpajen
|
||||
|
||||
Package: sensorpajen
|
||||
Architecture: all
|
||||
Depends: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, ${misc:Depends}
|
||||
Recommends: mosquitto-clients
|
||||
Description: Raspberry Pi Bluetooth temperature sensor monitor
|
||||
Monitors Xiaomi Mijia LYWSD03MMC temperature sensors via Bluetooth Low Energy
|
||||
and publishes readings to MQTT broker. Supports ATC firmware with automatic
|
||||
sensor discovery and approval workflow.
|
||||
```
|
||||
|
||||
#### 3. `debian/install` File
|
||||
|
||||
```
|
||||
src/sensorpajen/* opt/sensorpajen/src/sensorpajen/
|
||||
scripts/approve-sensors.sh opt/sensorpajen/scripts/
|
||||
pyproject.toml opt/sensorpajen/
|
||||
README.md usr/share/doc/sensorpajen/
|
||||
INSTALL.md usr/share/doc/sensorpajen/
|
||||
config/*.example usr/share/doc/sensorpajen/examples/
|
||||
```
|
||||
|
||||
#### 4. `debian/rules` File
|
||||
|
||||
```makefile
|
||||
#!/usr/bin/make -f
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_build:
|
||||
# No build step needed for pure Python
|
||||
|
||||
override_dh_auto_install:
|
||||
# Installation handled by debian/install file
|
||||
|
||||
override_dh_auto_clean:
|
||||
# Clean build artifacts
|
||||
rm -rf build/ dist/ *.egg-info
|
||||
```
|
||||
|
||||
#### 5. `debian/postinst` Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Create sensorpajen system user
|
||||
if ! getent passwd sensorpajen > /dev/null; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin sensorpajen
|
||||
fi
|
||||
|
||||
# Create config directory
|
||||
mkdir -p /etc/sensorpajen
|
||||
chown sensorpajen:sensorpajen /etc/sensorpajen
|
||||
|
||||
# Create virtual environment
|
||||
cd /opt/sensorpajen
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install --upgrade pip
|
||||
venv/bin/pip install .
|
||||
|
||||
# Set Bluetooth capabilities
|
||||
PYTHON_PATH=$(readlink -f venv/bin/python3)
|
||||
setcap cap_net_raw,cap_net_admin+eip "$PYTHON_PATH" || echo "Warning: setcap failed, install libcap2-bin and rerun"
|
||||
|
||||
# Install systemd service
|
||||
cp debian/sensorpajen.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
# Enable service (but don't start - needs config first)
|
||||
systemctl enable sensorpajen.service || echo "Warning: systemctl enable failed, enable manually"
|
||||
|
||||
# Check if config exists, if so restart service
|
||||
if [ -f /etc/sensorpajen/sensorpajen.env ] && [ -f /etc/sensorpajen/sensors.json ]; then
|
||||
systemctl restart sensorpajen.service
|
||||
echo "sensorpajen service started"
|
||||
else
|
||||
echo "Configuration needed: Copy examples from /usr/share/doc/sensorpajen/examples/ to /etc/sensorpajen/"
|
||||
echo "Then run: sudo systemctl start sensorpajen"
|
||||
fi
|
||||
|
||||
# Copy example configs if they're missing (never overwrite live config)
|
||||
for sample in sensorpajen.env.example sensors.json.example discovered_sensors.json.example; do
|
||||
target="/etc/sensorpajen/${sample%.example}"
|
||||
if [ ! -f "$target" ]; then
|
||||
cp "/usr/share/doc/sensorpajen/examples/$sample" "$target"
|
||||
chown sensorpajen:sensorpajen "$target"
|
||||
echo "Copied $sample to /etc/sensorpajen/"
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
```
|
||||
|
||||
#### 6. `debian/prerm` Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Stop service before removal
|
||||
if systemctl is-active --quiet sensorpajen.service; then
|
||||
systemctl stop sensorpajen.service
|
||||
fi
|
||||
|
||||
# Disable service
|
||||
systemctl disable sensorpajen.service || true
|
||||
|
||||
exit 0
|
||||
```
|
||||
|
||||
#### 7. `debian/postrm` Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove)
|
||||
# Service removed but config preserved
|
||||
echo "sensorpajen removed, config preserved in /etc/sensorpajen/"
|
||||
;;
|
||||
purge)
|
||||
# Even on purge, keep config (user choice to delete manually)
|
||||
echo "Config preserved in /etc/sensorpajen/ - delete manually if needed"
|
||||
# Could optionally remove user here, but safer to keep
|
||||
;;
|
||||
esac
|
||||
|
||||
# Clean up systemd
|
||||
systemctl daemon-reload || true
|
||||
|
||||
exit 0
|
||||
```
|
||||
|
||||
#### 8. `debian/sensorpajen.service` File
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Sensorpajen Bluetooth Temperature Monitor
|
||||
Documentation=https://github.com/fredrik/sensorpajen
|
||||
After=bluetooth.target network.target
|
||||
Wants=bluetooth.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sensorpajen
|
||||
Group=sensorpajen
|
||||
WorkingDirectory=/opt/sensorpajen
|
||||
EnvironmentFile=/etc/sensorpajen/sensorpajen.env
|
||||
ExecStart=/opt/sensorpajen/venv/bin/python -m sensorpajen.main
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Bluetooth capabilities require this to be false
|
||||
NoNewPrivileges=false
|
||||
|
||||
# Hardening (where possible with BT requirements)
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/etc/sensorpajen
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### 9. Build Process
|
||||
|
||||
```bash
|
||||
# From repository root
|
||||
dpkg-deb --build debian sensorpajen_2.0.0_armhf.deb
|
||||
|
||||
# Check package contents
|
||||
dpkg-deb -c sensorpajen_2.0.0_armhf.deb
|
||||
|
||||
# Check for issues
|
||||
lintian sensorpajen_2.0.0_armhf.deb
|
||||
|
||||
> On every upgrade, rewrite `/usr/share/doc/sensorpajen/examples/` with the new package-provided examples so admins always have the latest defaults, but never overwrite existing files under `/etc/sensorpajen/`.
|
||||
|
||||
### Automated Verification
|
||||
|
||||
Provide a script (e.g., `scripts/verify-deb.sh`) that runs the build and linting steps in a clean environment. The script should:
|
||||
|
||||
```
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
./ci/build-debian.sh # builds the deb into a temp directory
|
||||
lintian sensorpajen_*.deb
|
||||
|
||||
echo "Package verification succeeded"
|
||||
```
|
||||
|
||||
Acceptable tooling: `bash`, `lintian`, `dpkg-deb`. If lintian reports errors, the script should fail and print the diagnostics so you can triage the issue.
|
||||
```
|
||||
|
||||
#### 10. Installation Test
|
||||
|
||||
```bash
|
||||
# Install
|
||||
sudo dpkg -i sensorpajen_2.0.0_armhf.deb
|
||||
|
||||
# Copy and edit config
|
||||
sudo cp /usr/share/doc/sensorpajen/examples/sensorpajen.env.example /etc/sensorpajen/sensorpajen.env
|
||||
sudo cp /usr/share/doc/sensorpajen/examples/sensors.json.example /etc/sensorpajen/sensors.json
|
||||
sudo nano /etc/sensorpajen/sensorpajen.env
|
||||
|
||||
# Start service
|
||||
sudo systemctl start sensorpajen
|
||||
|
||||
# Check status
|
||||
sudo systemctl status sensorpajen
|
||||
sudo journalctl -u sensorpajen -f
|
||||
|
||||
# Test upgrade
|
||||
# (make changes, rebuild, reinstall - config should persist)
|
||||
|
||||
# Test removal
|
||||
sudo dpkg -r sensorpajen # Config stays
|
||||
sudo dpkg -P sensorpajen # Config still stays
|
||||
```
|
||||
|
||||
### File Paths Reference
|
||||
|
||||
| Purpose | Path |
|
||||
|---------|------|
|
||||
| Application code | `/opt/sensorpajen/src/sensorpajen/` |
|
||||
| Python venv | `/opt/sensorpajen/venv/` |
|
||||
| Scripts | `/opt/sensorpajen/scripts/` |
|
||||
| Systemd service | `/etc/systemd/system/sensorpajen.service` |
|
||||
| Active config | `/etc/sensorpajen/sensorpajen.env`, `/etc/sensorpajen/sensors.json` |
|
||||
| Discovery data | `/etc/sensorpajen/discovered_sensors.json` |
|
||||
| Example configs | `/usr/share/doc/sensorpajen/examples/*.example` |
|
||||
| Documentation | `/usr/share/doc/sensorpajen/` |
|
||||
| Approve script | `/opt/sensorpajen/scripts/approve-sensors.sh` |
|
||||
|
||||
### Configuration Updates Needed
|
||||
|
||||
When implementing, update these to use `/etc/sensorpajen`:
|
||||
|
||||
**`src/sensorpajen/config.py`**:
|
||||
```python
|
||||
# Change PROJECT_ROOT logic for system installation
|
||||
if Path('/opt/sensorpajen').exists():
|
||||
# System installation
|
||||
PROJECT_ROOT = Path('/opt/sensorpajen')
|
||||
CONFIG_DIR = Path('/etc/sensorpajen')
|
||||
else:
|
||||
# Development installation
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
```
|
||||
|
||||
**`scripts/approve-sensors.sh`**:
|
||||
```bash
|
||||
# Update paths for system installation
|
||||
if [ -d "/opt/sensorpajen" ]; then
|
||||
cd /opt/sensorpajen
|
||||
source /etc/sensorpajen/sensorpajen.env
|
||||
source venv/bin/activate
|
||||
else
|
||||
# Development mode
|
||||
cd "$(dirname "$0")/.."
|
||||
source config/sensorpajen.env
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Package is **system-wide**, not user-scoped
|
||||
- Config in `/etc/sensorpajen/` is **never** auto-deleted
|
||||
- Service runs as dedicated `sensorpajen` user for security
|
||||
- Virtual environment created post-install to handle PyPI dependencies
|
||||
- Bluetooth capabilities set automatically
|
||||
- Service enabled but not started until config exists
|
||||
- Follow Debian package naming: `sensorpajen_2.0.0_armhf.deb`
|
||||
- Test on fresh Pi before considering complete
|
||||
|
||||
---
|
||||
|
||||
## Task: Add Auto-Discovery and Approval Flow for Sensors
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Adding new sensors currently requires manually editing `sensors.json`, which is error-prone and inconvenient.
|
||||
The system should automatically detect new sensors and provide a controlled way for users to approve or ignore them.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Implement **automatic sensor discovery** with a **user approval workflow** that:
|
||||
|
||||
* Detects new sensors automatically
|
||||
* Notifies the user when new sensors are discovered
|
||||
* Allows the user to approve or ignore sensors via a script
|
||||
* Automatically updates `sensors.json` for approved sensors
|
||||
* Restarts the service after configuration changes
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
* Sensor auto-discovery
|
||||
* Tracking newly discovered sensors
|
||||
* Notification via `ntfy`
|
||||
* Interactive user script for approving/ignoring sensors
|
||||
* Updating `sensors.json`
|
||||
* Restarting the service via systemd
|
||||
|
||||
### Out of Scope
|
||||
|
||||
* Web UI
|
||||
* Authentication mechanisms beyond existing system access
|
||||
* Changes to sensor hardware or firmware
|
||||
* Long-term sensor management (removal, editing, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### 1. Sensor Auto-Discovery
|
||||
|
||||
* The service must detect sensors that are not present in `sensors.json`
|
||||
* Each newly discovered sensor must have a stable unique identifier
|
||||
* Discovered-but-unapproved sensors must **not** be added automatically
|
||||
|
||||
---
|
||||
|
||||
### 2. Discovered Sensor Storage
|
||||
|
||||
* Newly discovered sensors must be stored in `config/discovered_sensors.json`
|
||||
* Stored data must include:
|
||||
* `mac` - MAC address (unique identifier)
|
||||
* `name` - Advertised device name (e.g., "ATC_1234AB")
|
||||
* `rssi` - Signal strength in dBm
|
||||
* `first_seen` - ISO timestamp of first discovery
|
||||
* `last_seen` - ISO timestamp of most recent advertisement
|
||||
* `sample_reading` - One example reading with temperature, humidity, battery data
|
||||
* `status` - One of: "pending", "approved", "ignored"
|
||||
* `ignored_at` - ISO timestamp when ignored (if status is "ignored")
|
||||
* `ignore_reason` - Optional user-provided reason for ignoring
|
||||
* Approved sensors must have their status updated to "approved"
|
||||
* Ignored sensors must remain in the file with status "ignored"
|
||||
|
||||
---
|
||||
|
||||
### 3. Notification via ntfy
|
||||
|
||||
* When a new sensor is discovered:
|
||||
* Send a notification to the configured `ntfy` topic via curl
|
||||
* Include at least:
|
||||
* Sensor MAC address
|
||||
* Sensor name
|
||||
* Last seen timestamp
|
||||
* Instruction that user action is required
|
||||
* Configuration (in `config/sensorpajen.env`):
|
||||
* `NTFY_ENABLED` - true/false to enable/disable notifications
|
||||
* `NTFY_URL` - ntfy server URL (e.g., "https://ntfy.sh")
|
||||
* `NTFY_TOPIC` - Topic to publish to
|
||||
* `NTFY_TOKEN` - Authentication token (sent in header)
|
||||
* ntfy is optional - system must work without it:
|
||||
* If `NTFY_ENABLED=false`, skip notifications
|
||||
* If ntfy is unreachable, log error and continue
|
||||
* Discovery and approval must work even if ntfy fails
|
||||
* The user must only be notified once per discovered sensor
|
||||
---
|
||||
|
||||
### 4. User Approval Script
|
||||
|
||||
Provide a CLI command `sensorpajen approve-sensors` that:
|
||||
|
||||
* Lists all sensors with status "pending" or "ignored"
|
||||
* For each sensor, displays:
|
||||
* MAC address
|
||||
* Advertised name (e.g., "ATC_1234AB")
|
||||
* Last seen timestamp
|
||||
* Sample reading (temperature, humidity, battery)
|
||||
* Current status (pending/ignored)
|
||||
* For each sensor, allows the user to:
|
||||
* Approve the sensor (add to `sensors.json`)
|
||||
* Ignore the sensor (mark as ignored)
|
||||
* Skip (leave as pending for later)
|
||||
* If approving:
|
||||
* Prompt for a sensor name (required, human-readable)
|
||||
* Pre-fill comment field with extended metadata (MAC, device name, last seen, sample reading)
|
||||
* Allow user to edit or keep the pre-filled comment (optional)
|
||||
* If ignoring:
|
||||
* Prompt for optional reason
|
||||
* Update status to "ignored" with timestamp
|
||||
* Interactive mode only (no batch/automated approval)
|
||||
|
||||
---
|
||||
|
||||
### 5. Updating sensors.json
|
||||
|
||||
* When a sensor is approved:
|
||||
* Add it to `sensors.json` (only if MAC doesn't already exist)
|
||||
* Include:
|
||||
* `mac` - MAC address from discovery
|
||||
* `name` - User-provided human-readable name
|
||||
* `comment` - User-edited comment (pre-filled with metadata)
|
||||
* The file must remain valid JSON
|
||||
* Existing sensors must not be modified
|
||||
* If MAC already exists in `sensors.json`, skip adding (renaming is done manually in the file)
|
||||
* Update status to "approved" in `discovered_sensors.json`
|
||||
|
||||
---
|
||||
|
||||
### 6. Configuration Reload
|
||||
|
||||
* The service must automatically reload `sensors.json` every 15 minutes
|
||||
* No service restart required after approval
|
||||
* If `sensors.json` is modified:
|
||||
* Load new sensor list
|
||||
* Start monitoring newly added sensors
|
||||
* Continue monitoring existing sensors without interruption
|
||||
* Log configuration reload events
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
* Must be safe to run on a Raspberry Pi
|
||||
* Must not require a GUI
|
||||
* Must fail gracefully if:
|
||||
|
||||
* `ntfy` is unreachable
|
||||
* The user aborts the approval script
|
||||
* Logging must clearly indicate:
|
||||
|
||||
* Discovery events
|
||||
* Notifications sent
|
||||
* Approval or ignore decisions
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* A new sensor is automatically detected and added to `discovered_sensors.json` with status "pending"
|
||||
* Extended metadata (MAC, name, RSSI, timestamps, sample reading) is stored
|
||||
* A notification is sent via `ntfy` when a sensor is discovered (if enabled)
|
||||
* The approval CLI command (`sensorpajen approve-sensors`) lists pending and ignored sensors
|
||||
* The CLI displays MAC, name, last seen, and sample reading for each sensor
|
||||
* The user can approve a sensor with a custom name
|
||||
* The comment field is pre-filled with metadata and user can edit it
|
||||
* The user can ignore a sensor with an optional reason
|
||||
* Previously ignored sensors can be approved in a later CLI run
|
||||
* Approved sensors appear correctly in `sensors.json` (mac + name + comment only)
|
||||
* Sensors already in `sensors.json` are not added again (no duplicates)
|
||||
* The service automatically reloads `sensors.json` every 15 minutes
|
||||
* New sensors are monitored without service restart
|
||||
* Ignored sensors are stored with `ignored_at` timestamp and optional `ignore_reason`
|
||||
* ntfy failures do not prevent discovery or approval workflow
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
* Prefer environment-based configuration (no `.ini` files)
|
||||
* Keep the discovery logic separate from user interaction logic
|
||||
* Avoid race conditions between discovery and approval
|
||||
* Assume multiple sensors may be discovered before user action
|
||||
* Use MAC address as unique identifier for sensors
|
||||
* ntfy notification format: `curl -H "Authorization: Bearer $NTFY_TOKEN" -d "message" $NTFY_URL/$NTFY_TOPIC`
|
||||
* Config reload: Use a timer thread that checks file mtime or reloads every 15 minutes
|
||||
* Pre-filled comment example: `"MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:30:00, Temp: 21.5°C, Humidity: 45%, Battery: 87%"`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### File Locations
|
||||
* Discovered sensors: `config/discovered_sensors.json`
|
||||
* Known sensors: `config/sensors.json` (existing)
|
||||
* Configuration: `config/sensorpajen.env` (add ntfy settings)
|
||||
|
||||
### New CLI Command
|
||||
* Entry point: `sensorpajen approve-sensors`
|
||||
* Add to `pyproject.toml` under `[project.scripts]`
|
||||
|
||||
### Configuration Variables (add to sensorpajen.env)
|
||||
```bash
|
||||
# ntfy notifications (optional)
|
||||
NTFY_ENABLED=true
|
||||
NTFY_URL=https://ntfy.sh
|
||||
NTFY_TOPIC=sensorpajen
|
||||
NTFY_TOKEN=tk_xxxxxxxxxxxxx
|
||||
|
||||
# Config reload interval (seconds)
|
||||
CONFIG_RELOAD_INTERVAL=900 # 15 minutes
|
||||
```
|
||||
|
||||
### discovered_sensors.json Structure
|
||||
```json
|
||||
[
|
||||
{
|
||||
"mac": "A4:C1:38:12:34:56",
|
||||
"name": "ATC_1234AB",
|
||||
"rssi": -65,
|
||||
"first_seen": "2025-12-27T14:30:15",
|
||||
"last_seen": "2025-12-27T14:35:42",
|
||||
"sample_reading": {
|
||||
"temperature": 21.5,
|
||||
"humidity": 45,
|
||||
"battery_percent": 87,
|
||||
"battery_voltage": 2950
|
||||
},
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:AB:CD:EF",
|
||||
"name": "ATC_ABCDEF",
|
||||
"rssi": -72,
|
||||
"first_seen": "2025-12-27T15:00:00",
|
||||
"last_seen": "2025-12-27T15:10:00",
|
||||
"sample_reading": {
|
||||
"temperature": 19.8,
|
||||
"humidity": 52,
|
||||
"battery_percent": 65,
|
||||
"battery_voltage": 2800
|
||||
},
|
||||
"status": "ignored",
|
||||
"ignored_at": "2025-12-27T15:15:00",
|
||||
"ignore_reason": "Test sensor, not needed"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### sensors.json Entry (after approval)
|
||||
```json
|
||||
{
|
||||
"mac": "A4:C1:38:12:34:56",
|
||||
"name": "Living Room",
|
||||
"comment": "MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:35:42, Temp: 21.5°C, Humidity: 45%, Battery: 87%"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you want, I can also:
|
||||
|
||||
* Split this into **multiple smaller tasks**
|
||||
* Add a **definition of done** section
|
||||
* Provide a **suggested file/module structure**
|
||||
* Write a **follow-up roadmap entry** for sensor management
|
||||
|
||||
Just tell me how you want to evolve it next.
|
||||
32
config/discovered_sensors.json.example
Normal file
32
config/discovered_sensors.json.example
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"mac": "A4:C1:38:12:34:56",
|
||||
"name": "ATC_123456",
|
||||
"rssi": -65,
|
||||
"first_seen": "2025-12-27T10:30:15",
|
||||
"last_seen": "2025-12-27T10:35:42",
|
||||
"sample_reading": {
|
||||
"temperature": 21.5,
|
||||
"humidity": 45,
|
||||
"battery_percent": 87,
|
||||
"battery_voltage": 2950
|
||||
},
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:AB:CD:EF",
|
||||
"name": "ATC_ABCDEF",
|
||||
"rssi": -72,
|
||||
"first_seen": "2025-12-27T11:00:00",
|
||||
"last_seen": "2025-12-27T11:10:00",
|
||||
"sample_reading": {
|
||||
"temperature": 19.8,
|
||||
"humidity": 52,
|
||||
"battery_percent": 65,
|
||||
"battery_voltage": 2800
|
||||
},
|
||||
"status": "ignored",
|
||||
"ignored_at": "2025-12-27T11:15:00",
|
||||
"ignore_reason": "Test sensor, not needed"
|
||||
}
|
||||
]
|
||||
29
config/sensorpajen.env.example
Normal file
29
config/sensorpajen.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# MQTT Configuration
|
||||
MQTT_HOST=192.168.0.114
|
||||
MQTT_PORT=1883
|
||||
MQTT_USER=hasse
|
||||
MQTT_PASSWORD=casablanca
|
||||
MQTT_CLIENT_ID=mibridge
|
||||
|
||||
# Sensor Configuration
|
||||
# For system installation (/opt/sensorpajen): Use absolute paths
|
||||
# SENSOR_CONFIG_FILE=/etc/sensorpajen/sensors.json
|
||||
# DISCOVERED_SENSORS_FILE=/etc/sensorpajen/discovered_sensors.json
|
||||
#
|
||||
# For development installation: Use relative paths (from project root)
|
||||
# SENSOR_CONFIG_FILE=config/sensors.json
|
||||
# DISCOVERED_SENSORS_FILE=config/discovered_sensors.json
|
||||
#
|
||||
# If not set, defaults will be used based on installation type
|
||||
|
||||
# Application Settings
|
||||
WATCHDOG_TIMEOUT=5
|
||||
ENABLE_BATTERY=true
|
||||
LOG_LEVEL=INFO
|
||||
CONFIG_RELOAD_INTERVAL=900
|
||||
|
||||
# ntfy Notifications (optional)
|
||||
NTFY_ENABLED=false
|
||||
NTFY_URL=https://ntfy.sh
|
||||
NTFY_TOPIC=sensorpajen
|
||||
NTFY_TOKEN=
|
||||
37
config/sensors.json.example
Normal file
37
config/sensors.json.example
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"sensors": [
|
||||
{
|
||||
"mac": "A4:C1:38:98:7B:B6",
|
||||
"name": "mi_temp_1",
|
||||
"comment": "Example sensor - replace with your sensors"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:29:03:0D",
|
||||
"name": "mi_temp_2"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:62:CA:83",
|
||||
"name": "mi_temp_3"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:D5:EA:63",
|
||||
"name": "mi_temp_4"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:7C:9C:63",
|
||||
"name": "mi_temp_5"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:68:2C:DA",
|
||||
"name": "mi_temp_6"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:AD:74:2B",
|
||||
"name": "mi_temp_7"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:46:9F:D1",
|
||||
"name": "mi_temp_8"
|
||||
}
|
||||
]
|
||||
}
|
||||
353
debian/README.md
vendored
Normal file
353
debian/README.md
vendored
Normal file
@@ -0,0 +1,353 @@
|
||||
# Debian Package Build Guide
|
||||
|
||||
This directory contains the Debian packaging files for **sensorpajen**, a Bluetooth temperature sensor monitor for Raspberry Pi.
|
||||
|
||||
## Overview
|
||||
|
||||
The Debian package installs sensorpajen as a **system-wide service** with:
|
||||
|
||||
- Installation to `/opt/sensorpajen/`
|
||||
- Configuration in `/etc/sensorpajen/`
|
||||
- Dedicated `sensorpajen` system user
|
||||
- Systemd service integration
|
||||
- Automatic Python virtual environment setup
|
||||
- Bluetooth capability configuration
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Packages
|
||||
|
||||
```bash
|
||||
sudo apt install \
|
||||
debhelper \
|
||||
dpkg-dev \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip
|
||||
```
|
||||
|
||||
### Optional (for verification)
|
||||
|
||||
```bash
|
||||
sudo apt install lintian
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Automated Build and Verification
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
./scripts/verify-deb.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Check for required tools
|
||||
2. Build the package
|
||||
3. Show package contents
|
||||
4. Run lintian checks
|
||||
5. Display installation instructions
|
||||
|
||||
### Manual Build
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
dpkg-buildpackage -us -uc -b
|
||||
```
|
||||
|
||||
The `.deb` file will be created in the parent directory:
|
||||
```bash
|
||||
ls -lh ../sensorpajen_*.deb
|
||||
```
|
||||
|
||||
## Build Output
|
||||
|
||||
```
|
||||
../sensorpajen_2.0.0-dev_all.deb # Installable package
|
||||
../sensorpajen_2.0.0-dev_armhf.build # Build log
|
||||
../sensorpajen_2.0.0-dev_armhf.buildinfo # Build metadata
|
||||
../sensorpajen_2.0.0-dev_armhf.changes # Changes file
|
||||
```
|
||||
|
||||
## Package Verification
|
||||
|
||||
### Check Package Contents
|
||||
|
||||
```bash
|
||||
dpkg-deb -c ../sensorpajen_*.deb
|
||||
```
|
||||
|
||||
### Check Package Metadata
|
||||
|
||||
```bash
|
||||
dpkg-deb -I ../sensorpajen_*.deb
|
||||
```
|
||||
|
||||
### Run Lintian
|
||||
|
||||
```bash
|
||||
lintian ../sensorpajen_*.deb
|
||||
```
|
||||
|
||||
**Note**: Warnings are acceptable. Focus on fixing errors.
|
||||
|
||||
## Installation
|
||||
|
||||
### On Raspberry Pi
|
||||
|
||||
```bash
|
||||
# Copy package to Pi
|
||||
scp ../sensorpajen_*.deb pi@raspberrypi:~/
|
||||
|
||||
# SSH to Pi and install
|
||||
ssh pi@raspberrypi
|
||||
sudo apt install ./sensorpajen_*.deb
|
||||
```
|
||||
|
||||
### Local Testing (Not Recommended)
|
||||
|
||||
Installing on your development machine will modify `/opt` and `/etc`:
|
||||
|
||||
```bash
|
||||
sudo apt install ../sensorpajen_*.deb
|
||||
```
|
||||
|
||||
**Warning**: This will create system directories and a system user on your dev machine.
|
||||
|
||||
## Post-Installation Configuration
|
||||
|
||||
After installing the package:
|
||||
|
||||
```bash
|
||||
# 1. Edit MQTT credentials
|
||||
sudo nano /etc/sensorpajen/sensorpajen.env
|
||||
|
||||
# 2. Configure sensors
|
||||
sudo nano /etc/sensorpajen/sensors.json
|
||||
|
||||
# 3. Start the service
|
||||
sudo systemctl start sensorpajen
|
||||
|
||||
# 4. Check status
|
||||
sudo systemctl status sensorpajen
|
||||
|
||||
# 5. View logs
|
||||
sudo journalctl -u sensorpajen -f
|
||||
```
|
||||
|
||||
## Package Structure
|
||||
|
||||
### Installed Files
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `src/sensorpajen/*.py` | `/opt/sensorpajen/src/sensorpajen/` |
|
||||
| `scripts/approve-sensors.sh` | `/opt/sensorpajen/scripts/` |
|
||||
| `pyproject.toml` | `/opt/sensorpajen/` |
|
||||
| `README.md`, `INSTALL.md`, `ROADMAP.md` | `/usr/share/doc/sensorpajen/` |
|
||||
| `config/*.example` | `/usr/share/doc/sensorpajen/examples/` |
|
||||
| `debian/sensorpajen.service` | `/etc/systemd/system/` |
|
||||
| *(created in postinst)* | `/opt/sensorpajen/venv/` |
|
||||
| *(created in postinst)* | `/etc/sensorpajen/` |
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- **Active Config**: `/etc/sensorpajen/sensorpajen.env` (credentials)
|
||||
- **Active Config**: `/etc/sensorpajen/sensors.json` (sensor list)
|
||||
- **Discovery Data**: `/etc/sensorpajen/discovered_sensors.json`
|
||||
- **Examples**: `/usr/share/doc/sensorpajen/examples/*.example`
|
||||
|
||||
## Maintainer Scripts
|
||||
|
||||
### postinst (Post-Installation)
|
||||
|
||||
Runs after package installation:
|
||||
|
||||
1. Creates `sensorpajen` system user (if doesn't exist)
|
||||
2. Creates `/etc/sensorpajen/` directory
|
||||
3. Copies example configs to `/etc/sensorpajen/` (if missing)
|
||||
4. Creates Python virtual environment in `/opt/sensorpajen/venv/`
|
||||
5. Installs Python dependencies via pip
|
||||
6. Sets Bluetooth capabilities on Python executable
|
||||
7. Installs systemd service file
|
||||
8. Enables service (but doesn't start until configured)
|
||||
|
||||
### prerm (Pre-Removal)
|
||||
|
||||
Runs before package removal:
|
||||
|
||||
1. Stops the sensorpajen service
|
||||
2. Disables the service (on remove, not upgrade)
|
||||
|
||||
### postrm (Post-Removal)
|
||||
|
||||
Runs after package removal:
|
||||
|
||||
1. Removes systemd service file
|
||||
2. Reloads systemd daemon
|
||||
3. **Preserves** configuration in `/etc/sensorpajen/`
|
||||
4. **Preserves** `sensorpajen` user
|
||||
|
||||
**Note**: Configuration and user are intentionally preserved to prevent data loss.
|
||||
|
||||
## Upgrade Behavior
|
||||
|
||||
When upgrading to a new version:
|
||||
|
||||
```bash
|
||||
sudo apt install ./sensorpajen_2.1.0_all.deb
|
||||
```
|
||||
|
||||
- ✅ Service is stopped during upgrade
|
||||
- ✅ Old files are replaced
|
||||
- ✅ Configuration in `/etc/sensorpajen/` is **preserved**
|
||||
- ✅ Python dependencies are updated
|
||||
- ✅ Service is restarted after upgrade
|
||||
- ✅ Example files in `/usr/share/doc/` are updated
|
||||
|
||||
## Removal Behavior
|
||||
|
||||
### Remove (Keep Config)
|
||||
|
||||
```bash
|
||||
sudo apt remove sensorpajen
|
||||
```
|
||||
|
||||
- Service stopped and disabled
|
||||
- Application files removed from `/opt/sensorpajen/`
|
||||
- Configuration **preserved** in `/etc/sensorpajen/`
|
||||
- User **preserved**
|
||||
|
||||
### Purge (Still Keeps Config)
|
||||
|
||||
```bash
|
||||
sudo apt purge sensorpajen
|
||||
```
|
||||
|
||||
- Same as remove
|
||||
- Configuration still **preserved** (by design, for safety)
|
||||
- User still **preserved**
|
||||
|
||||
### Complete Removal
|
||||
|
||||
To completely remove everything:
|
||||
|
||||
```bash
|
||||
sudo apt purge sensorpajen
|
||||
sudo rm -rf /etc/sensorpajen
|
||||
sudo userdel sensorpajen
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Fails: "debhelper: command not found"
|
||||
|
||||
```bash
|
||||
sudo apt install debhelper
|
||||
```
|
||||
|
||||
### Build Fails: "dh_python3: command not found"
|
||||
|
||||
```bash
|
||||
sudo apt install dh-python
|
||||
```
|
||||
|
||||
### Lintian Warnings About Permissions
|
||||
|
||||
The postinst script runs as root and sets file permissions. This is expected and safe.
|
||||
|
||||
### Package Won't Install: Dependency Issues
|
||||
|
||||
```bash
|
||||
# Fix missing dependencies
|
||||
sudo apt install -f
|
||||
```
|
||||
|
||||
### Service Won't Start After Install
|
||||
|
||||
Check if configuration has been edited:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u sensorpajen -n 50
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- MQTT_HOST still has example value
|
||||
- sensors.json is empty
|
||||
- Bluetooth adapter not available
|
||||
|
||||
### Bluetooth Capability Not Set
|
||||
|
||||
```bash
|
||||
# Manually set capability
|
||||
sudo setcap cap_net_raw,cap_net_admin+eip $(readlink -f /opt/sensorpajen/venv/bin/python3)
|
||||
|
||||
# Verify
|
||||
getcap $(readlink -f /opt/sensorpajen/venv/bin/python3)
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Making Changes
|
||||
|
||||
1. Edit source code in `src/sensorpajen/`
|
||||
2. Update version in `pyproject.toml`
|
||||
3. Update `debian/changelog` with new entry
|
||||
4. Rebuild package: `./scripts/verify-deb.sh`
|
||||
5. Test on Raspberry Pi
|
||||
|
||||
### Version Numbering
|
||||
|
||||
- Development: `2.0.0-dev`
|
||||
- Release: `2.0.0`
|
||||
- Patch: `2.0.1`
|
||||
|
||||
Update in both:
|
||||
- `pyproject.toml` (line 6: `version = "..."`)
|
||||
- `debian/changelog` (first line)
|
||||
|
||||
### Testing on Pi
|
||||
|
||||
```bash
|
||||
# Build
|
||||
./scripts/verify-deb.sh
|
||||
|
||||
# Copy to Pi
|
||||
scp ../sensorpajen_*.deb pi@raspberrypi:~/
|
||||
|
||||
# Install on Pi
|
||||
ssh pi@raspberrypi
|
||||
sudo systemctl stop sensorpajen # If upgrading
|
||||
sudo apt install ./sensorpajen_*.deb
|
||||
sudo systemctl status sensorpajen
|
||||
```
|
||||
|
||||
## Package Metadata
|
||||
|
||||
**Package Name**: sensorpajen
|
||||
**Section**: misc
|
||||
**Priority**: optional
|
||||
**Architecture**: all (pure Python)
|
||||
**Maintainer**: Fredrik <fredrik@wahlberg.se>
|
||||
**Depends**: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, libcap2-bin
|
||||
**Recommends**: mosquitto-clients
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **TASKS.md**: Detailed implementation notes
|
||||
- **ROADMAP.md**: Phase 8 section for APT package creation
|
||||
- **INSTALL.md**: User installation guide
|
||||
- **systemd/README.md**: Service management guide
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check `sudo journalctl -u sensorpajen -n 100`
|
||||
2. Verify configuration files in `/etc/sensorpajen/`
|
||||
3. Check Bluetooth adapter: `hciconfig`
|
||||
4. Test MQTT connection: `mosquitto_pub -h <host> -t test -m "test"`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: December 27, 2025
|
||||
**Package Version**: 2.0.0-dev
|
||||
19
debian/changelog
vendored
Normal file
19
debian/changelog
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
sensorpajen (2.0.0) stable; urgency=medium
|
||||
|
||||
* Production release v2.0.0
|
||||
* Modernized service architecture with systemd
|
||||
* Automatic sensor discovery and approval workflow
|
||||
* Fixed state directory for discovered_sensors.json
|
||||
* Improved documentation with installation guide
|
||||
* Bytecode cleanup in postinst for clean installs
|
||||
|
||||
-- Fredrik <fredrik@wahlberg.se> Sun, 28 Dec 2025 10:56:00 +0100
|
||||
|
||||
sensorpajen (2.0.0-dev) unstable; urgency=medium
|
||||
|
||||
* Initial Debian package release
|
||||
* Modernized service architecture with systemd
|
||||
* Automatic sensor discovery and approval workflow
|
||||
|
||||
-- Fredrik <fredrik@wahlberg.se> Sun, 28 Dec 2025 09:00:00 +0100
|
||||
|
||||
22
debian/control
vendored
Normal file
22
debian/control
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
Source: sensorpajen
|
||||
Section: misc
|
||||
Priority: optional
|
||||
Maintainer: Fredrik <fredrik@wahlberg.se>
|
||||
Build-Depends: debhelper-compat (= 13)
|
||||
Standards-Version: 4.5.0
|
||||
Homepage: https://github.com/yourusername/sensorpajen
|
||||
|
||||
Package: sensorpajen
|
||||
Architecture: all
|
||||
Depends: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, libcap2-bin, ${misc:Depends}
|
||||
Recommends: mosquitto-clients
|
||||
Description: Raspberry Pi Bluetooth temperature sensor monitor
|
||||
Monitors Xiaomi Mijia LYWSD03MMC temperature sensors via Bluetooth Low Energy
|
||||
and publishes readings to MQTT broker. Supports ATC firmware with automatic
|
||||
sensor discovery and approval workflow.
|
||||
.
|
||||
Features:
|
||||
- Automatic sensor discovery
|
||||
- MQTT publishing
|
||||
- Systemd service integration
|
||||
- User approval workflow for new sensors
|
||||
1
debian/debhelper-build-stamp
vendored
Normal file
1
debian/debhelper-build-stamp
vendored
Normal file
@@ -0,0 +1 @@
|
||||
sensorpajen
|
||||
1
debian/files
vendored
Normal file
1
debian/files
vendored
Normal file
@@ -0,0 +1 @@
|
||||
sensorpajen_2.0.0-dev_all.deb misc optional
|
||||
8
debian/install
vendored
Normal file
8
debian/install
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
src/sensorpajen/*.py opt/sensorpajen/src/sensorpajen/
|
||||
scripts/approve-sensors.sh opt/sensorpajen/scripts/
|
||||
pyproject.toml opt/sensorpajen/
|
||||
requirements.txt opt/sensorpajen/
|
||||
readme.md usr/share/doc/sensorpajen/
|
||||
INSTALL.md usr/share/doc/sensorpajen/
|
||||
ROADMAP.md usr/share/doc/sensorpajen/
|
||||
config/*.example usr/share/doc/sensorpajen/examples/
|
||||
151
debian/postinst
vendored
Executable file
151
debian/postinst
vendored
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# Create sensorpajen system user if it doesn't exist
|
||||
if ! getent passwd sensorpajen > /dev/null; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin sensorpajen
|
||||
echo "Created system user: sensorpajen"
|
||||
fi
|
||||
|
||||
# Create config directory with proper permissions
|
||||
mkdir -p /etc/sensorpajen
|
||||
chown sensorpajen:sensorpajen /etc/sensorpajen
|
||||
chmod 750 /etc/sensorpajen
|
||||
|
||||
# Create state directory with proper permissions (writable at runtime)
|
||||
mkdir -p /var/lib/sensorpajen
|
||||
chown sensorpajen:sensorpajen /var/lib/sensorpajen
|
||||
chmod 750 /var/lib/sensorpajen
|
||||
|
||||
# Copy example configs to /etc/sensorpajen if they don't exist
|
||||
for sample in sensorpajen.env.example sensors.json.example; do
|
||||
source_file="/usr/share/doc/sensorpajen/examples/$sample"
|
||||
target_file="/etc/sensorpajen/${sample%.example}"
|
||||
|
||||
if [ -f "$source_file" ] && [ ! -f "$target_file" ]; then
|
||||
cp "$source_file" "$target_file"
|
||||
chown sensorpajen:sensorpajen "$target_file"
|
||||
|
||||
# Set restrictive permissions on env file (contains credentials)
|
||||
if [ "$sample" = "sensorpajen.env.example" ]; then
|
||||
chmod 600 "$target_file"
|
||||
echo "Created $target_file (edit this file with your MQTT credentials)"
|
||||
else
|
||||
chmod 640 "$target_file"
|
||||
echo "Created $target_file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Create virtual environment in /opt/sensorpajen
|
||||
cd /opt/sensorpajen
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Creating Python virtual environment..."
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install --upgrade pip setuptools wheel
|
||||
fi
|
||||
|
||||
# Install Python dependencies from requirements.txt
|
||||
echo "Installing Python dependencies..."
|
||||
if [ -f "/opt/sensorpajen/requirements.txt" ]; then
|
||||
venv/bin/pip install -r /opt/sensorpajen/requirements.txt
|
||||
else
|
||||
echo "Warning: requirements.txt not found, installing bluepy and paho-mqtt directly"
|
||||
venv/bin/pip install bluepy paho-mqtt pybluez
|
||||
fi
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to install dependencies"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install sensorpajen package itself
|
||||
echo "Installing sensorpajen application..."
|
||||
cd /opt/sensorpajen
|
||||
# Clean up any stale bytecode before building wheel
|
||||
find . -name "*.pyc" -delete
|
||||
find . -name "__pycache__" -type d -delete
|
||||
venv/bin/pip install --no-deps . || {
|
||||
echo "Error: Failed to install sensorpajen package"
|
||||
exit 1
|
||||
}
|
||||
cd /
|
||||
|
||||
# Set ownership of application directory BEFORE setting capabilities
|
||||
chown -R sensorpajen:sensorpajen /opt/sensorpajen
|
||||
|
||||
# Set Bluetooth capabilities on Python executable (after ownership change)
|
||||
PYTHON_PATH=$(readlink -f /opt/sensorpajen/venv/bin/python3)
|
||||
if command -v setcap >/dev/null 2>&1; then
|
||||
setcap cap_net_raw,cap_net_admin+eip "$PYTHON_PATH" || {
|
||||
echo "Warning: setcap failed. You may need to run Bluetooth operations as root."
|
||||
echo "Try: sudo setcap cap_net_raw,cap_net_admin+eip $PYTHON_PATH"
|
||||
}
|
||||
else
|
||||
echo "Warning: setcap not found (install libcap2-bin package)"
|
||||
fi
|
||||
|
||||
# Install systemd service file
|
||||
if [ -f /opt/sensorpajen/debian/sensorpajen.service ]; then
|
||||
cp /opt/sensorpajen/debian/sensorpajen.service /etc/systemd/system/
|
||||
elif [ -f /usr/share/doc/sensorpajen/sensorpajen.service ]; then
|
||||
cp /usr/share/doc/sensorpajen/sensorpajen.service /etc/systemd/system/
|
||||
fi
|
||||
|
||||
# Reload systemd
|
||||
systemctl daemon-reload
|
||||
|
||||
# Enable service (but don't start - needs configuration first)
|
||||
systemctl enable sensorpajen.service || {
|
||||
echo "Warning: Could not enable sensorpajen service"
|
||||
}
|
||||
|
||||
# Check if configuration is ready
|
||||
if [ -f /etc/sensorpajen/sensorpajen.env ] && [ -f /etc/sensorpajen/sensors.json ]; then
|
||||
# Check if env file has been configured (not default values)
|
||||
if grep -q "MQTT_HOST=192.168.0.114" /etc/sensorpajen/sensorpajen.env; then
|
||||
echo ""
|
||||
echo "======================================================================"
|
||||
echo " Configuration needed!"
|
||||
echo "======================================================================"
|
||||
echo " Edit /etc/sensorpajen/sensorpajen.env with your MQTT settings"
|
||||
echo " Edit /etc/sensorpajen/sensors.json with your sensor list"
|
||||
echo " Then run: sudo systemctl start sensorpajen"
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
else
|
||||
# Configuration appears to be customized, restart service
|
||||
systemctl restart sensorpajen.service && {
|
||||
echo "Sensorpajen service started"
|
||||
echo "View logs: sudo journalctl -u sensorpajen -f"
|
||||
} || {
|
||||
echo "Failed to start service. Check: sudo systemctl status sensorpajen"
|
||||
}
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "======================================================================"
|
||||
echo " Sensorpajen installed successfully!"
|
||||
echo "======================================================================"
|
||||
echo " Next steps:"
|
||||
echo " 1. Edit /etc/sensorpajen/sensorpajen.env"
|
||||
echo " 2. Edit /etc/sensorpajen/sensors.json"
|
||||
echo " 3. sudo systemctl start sensorpajen"
|
||||
echo " 4. sudo journalctl -u sensorpajen -f"
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
fi
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postinst called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
41
debian/postrm
vendored
Executable file
41
debian/postrm
vendored
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove)
|
||||
# Service removed but config and user preserved
|
||||
echo "Sensorpajen removed. Configuration preserved in /etc/sensorpajen/"
|
||||
echo "To remove config: sudo rm -rf /etc/sensorpajen/"
|
||||
|
||||
# Remove systemd service file
|
||||
rm -f /etc/systemd/system/sensorpajen.service
|
||||
systemctl daemon-reload || true
|
||||
;;
|
||||
|
||||
purge)
|
||||
# Even on purge, we keep config by default (user can manually delete)
|
||||
# This is safer as it prevents accidental data loss
|
||||
echo "Configuration preserved in /etc/sensorpajen/"
|
||||
echo "To remove config: sudo rm -rf /etc/sensorpajen/"
|
||||
echo "To remove user: sudo userdel sensorpajen"
|
||||
|
||||
# Remove systemd service file
|
||||
rm -f /etc/systemd/system/sensorpajen.service
|
||||
systemctl daemon-reload || true
|
||||
|
||||
# Note: We intentionally do NOT remove:
|
||||
# - /etc/sensorpajen (contains user data)
|
||||
# - sensorpajen user (may own other files/processes)
|
||||
# User must remove these manually if desired
|
||||
;;
|
||||
|
||||
upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postrm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
27
debian/prerm
vendored
Executable file
27
debian/prerm
vendored
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|upgrade|deconfigure)
|
||||
# Stop service before removal or upgrade
|
||||
if systemctl is-active --quiet sensorpajen.service 2>/dev/null; then
|
||||
echo "Stopping sensorpajen service..."
|
||||
systemctl stop sensorpajen.service || true
|
||||
fi
|
||||
|
||||
# Disable service on removal (not upgrade)
|
||||
if [ "$1" = "remove" ]; then
|
||||
systemctl disable sensorpajen.service || true
|
||||
fi
|
||||
;;
|
||||
|
||||
failed-upgrade)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "prerm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
20
debian/rules
vendored
Executable file
20
debian/rules
vendored
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_build:
|
||||
# No build step needed for pure Python
|
||||
|
||||
override_dh_auto_install:
|
||||
# Installation handled by debian/install file
|
||||
dh_auto_install
|
||||
|
||||
override_dh_auto_clean:
|
||||
# Clean build artifacts
|
||||
rm -rf build/ dist/ *.egg-info
|
||||
rm -rf src/*.egg-info
|
||||
|
||||
override_dh_builddeb:
|
||||
# Use gzip compression for better compatibility
|
||||
dh_builddeb -- -Zgzip
|
||||
1
debian/sensorpajen.debhelper.log
vendored
Normal file
1
debian/sensorpajen.debhelper.log
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dh_builddeb
|
||||
12
debian/sensorpajen.postrm.debhelper
vendored
Normal file
12
debian/sensorpajen.postrm.debhelper
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Automatically added by dh_installsystemd/13.14.1ubuntu5
|
||||
if [ "$1" = remove ] && [ -d /run/systemd/system ] ; then
|
||||
systemctl --system daemon-reload >/dev/null || true
|
||||
fi
|
||||
# End automatically added section
|
||||
# Automatically added by dh_installsystemd/13.14.1ubuntu5
|
||||
if [ "$1" = "purge" ]; then
|
||||
if [ -x "/usr/bin/deb-systemd-helper" ]; then
|
||||
deb-systemd-helper purge 'sensorpajen.service' >/dev/null || true
|
||||
fi
|
||||
fi
|
||||
# End automatically added section
|
||||
32
debian/sensorpajen.service
vendored
Normal file
32
debian/sensorpajen.service
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=Sensorpajen - Bluetooth Temperature Sensor Monitor
|
||||
Documentation=https://github.com/yourusername/sensorpajen
|
||||
After=network.target bluetooth.target
|
||||
Wants=bluetooth.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sensorpajen
|
||||
Group=sensorpajen
|
||||
WorkingDirectory=/opt/sensorpajen
|
||||
EnvironmentFile=/etc/sensorpajen/sensorpajen.env
|
||||
ExecStart=/opt/sensorpajen/venv/bin/python -m sensorpajen.main
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Bluetooth capabilities require this to be false
|
||||
NoNewPrivileges=false
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=sensorpajen
|
||||
|
||||
# Security hardening (where possible with Bluetooth requirements)
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/etc/sensorpajen /var/lib/sensorpajen
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
2
debian/sensorpajen.substvars
vendored
Normal file
2
debian/sensorpajen.substvars
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
misc:Depends=
|
||||
misc:Pre-Depends=
|
||||
20
debian/sensorpajen/DEBIAN/control
vendored
Normal file
20
debian/sensorpajen/DEBIAN/control
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
Package: sensorpajen
|
||||
Version: 2.0.0-dev
|
||||
Architecture: all
|
||||
Maintainer: Fredrik <fredrik@wahlberg.se>
|
||||
Installed-Size: 112
|
||||
Depends: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, libcap2-bin
|
||||
Recommends: mosquitto-clients
|
||||
Section: misc
|
||||
Priority: optional
|
||||
Homepage: https://github.com/yourusername/sensorpajen
|
||||
Description: Raspberry Pi Bluetooth temperature sensor monitor
|
||||
Monitors Xiaomi Mijia LYWSD03MMC temperature sensors via Bluetooth Low Energy
|
||||
and publishes readings to MQTT broker. Supports ATC firmware with automatic
|
||||
sensor discovery and approval workflow.
|
||||
.
|
||||
Features:
|
||||
- Automatic sensor discovery
|
||||
- MQTT publishing
|
||||
- Systemd service integration
|
||||
- User approval workflow for new sensors
|
||||
19
debian/sensorpajen/DEBIAN/md5sums
vendored
Normal file
19
debian/sensorpajen/DEBIAN/md5sums
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
3b3c15c00bf48fc519b8fbe507a93a7e opt/sensorpajen/pyproject.toml
|
||||
0894789523a53bb372980c0906a7d0b5 opt/sensorpajen/requirements.txt
|
||||
940d73f24eb9f971ce27f9355e3072f3 opt/sensorpajen/scripts/approve-sensors.sh
|
||||
20eb4f3839b990a530410768897402c0 opt/sensorpajen/src/sensorpajen/__init__.py
|
||||
3c6c65213de874065f81b7b3d8948c8b opt/sensorpajen/src/sensorpajen/approve_sensors.py
|
||||
f69225e19918cca05351fa2da8fd7618 opt/sensorpajen/src/sensorpajen/config.py
|
||||
65c63383dde4f0b249b708f854ec75a3 opt/sensorpajen/src/sensorpajen/discovery_manager.py
|
||||
7604c2bc0a854d6d43ff0f0646386fc5 opt/sensorpajen/src/sensorpajen/main.py
|
||||
331bf9b314492acc6ce03896367f3cf6 opt/sensorpajen/src/sensorpajen/mqtt_publisher.py
|
||||
5f4ea191e35ce092f39ec0a4f663cb38 opt/sensorpajen/src/sensorpajen/sensor_reader.py
|
||||
c8dd8fe8fc174a9cd35251fdf80e7b5f opt/sensorpajen/src/sensorpajen/utils.py
|
||||
b9ad3ea8307d8ed8e938da37ad00f229 usr/lib/systemd/system/sensorpajen.service
|
||||
4ddb9618c940286f91df901ec818959a usr/share/doc/sensorpajen/INSTALL.md.gz
|
||||
bd2f1371c60af415bc9d0dbc1111184d usr/share/doc/sensorpajen/ROADMAP.md.gz
|
||||
380e8e6b01b757ceac05bc5805844ae4 usr/share/doc/sensorpajen/changelog.Debian.gz
|
||||
14152a98d7cd7fe8daf280aacc4cbf3f usr/share/doc/sensorpajen/examples/discovered_sensors.json.example
|
||||
74c99b732363f93f0a1c134e1a8c3d35 usr/share/doc/sensorpajen/examples/sensorpajen.env.example
|
||||
292efbddd951c39cb2c9546d5fac5e05 usr/share/doc/sensorpajen/examples/sensors.json.example
|
||||
5f647c63bfc3b174611694779fd215e0 usr/share/doc/sensorpajen/readme.md.gz
|
||||
151
debian/sensorpajen/DEBIAN/postinst
vendored
Executable file
151
debian/sensorpajen/DEBIAN/postinst
vendored
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# Create sensorpajen system user if it doesn't exist
|
||||
if ! getent passwd sensorpajen > /dev/null; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin sensorpajen
|
||||
echo "Created system user: sensorpajen"
|
||||
fi
|
||||
|
||||
# Create config directory with proper permissions
|
||||
mkdir -p /etc/sensorpajen
|
||||
chown sensorpajen:sensorpajen /etc/sensorpajen
|
||||
chmod 750 /etc/sensorpajen
|
||||
|
||||
# Create state directory with proper permissions (writable at runtime)
|
||||
mkdir -p /var/lib/sensorpajen
|
||||
chown sensorpajen:sensorpajen /var/lib/sensorpajen
|
||||
chmod 750 /var/lib/sensorpajen
|
||||
|
||||
# Copy example configs to /etc/sensorpajen if they don't exist
|
||||
for sample in sensorpajen.env.example sensors.json.example; do
|
||||
source_file="/usr/share/doc/sensorpajen/examples/$sample"
|
||||
target_file="/etc/sensorpajen/${sample%.example}"
|
||||
|
||||
if [ -f "$source_file" ] && [ ! -f "$target_file" ]; then
|
||||
cp "$source_file" "$target_file"
|
||||
chown sensorpajen:sensorpajen "$target_file"
|
||||
|
||||
# Set restrictive permissions on env file (contains credentials)
|
||||
if [ "$sample" = "sensorpajen.env.example" ]; then
|
||||
chmod 600 "$target_file"
|
||||
echo "Created $target_file (edit this file with your MQTT credentials)"
|
||||
else
|
||||
chmod 640 "$target_file"
|
||||
echo "Created $target_file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Create virtual environment in /opt/sensorpajen
|
||||
cd /opt/sensorpajen
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Creating Python virtual environment..."
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install --upgrade pip setuptools wheel
|
||||
fi
|
||||
|
||||
# Install Python dependencies from requirements.txt
|
||||
echo "Installing Python dependencies..."
|
||||
if [ -f "/opt/sensorpajen/requirements.txt" ]; then
|
||||
venv/bin/pip install -r /opt/sensorpajen/requirements.txt
|
||||
else
|
||||
echo "Warning: requirements.txt not found, installing bluepy and paho-mqtt directly"
|
||||
venv/bin/pip install bluepy paho-mqtt pybluez
|
||||
fi
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to install dependencies"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install sensorpajen package itself
|
||||
echo "Installing sensorpajen application..."
|
||||
cd /opt/sensorpajen
|
||||
# Clean up any stale bytecode before building wheel
|
||||
find . -name "*.pyc" -delete
|
||||
find . -name "__pycache__" -type d -delete
|
||||
venv/bin/pip install --no-deps . || {
|
||||
echo "Error: Failed to install sensorpajen package"
|
||||
exit 1
|
||||
}
|
||||
cd /
|
||||
|
||||
# Set ownership of application directory BEFORE setting capabilities
|
||||
chown -R sensorpajen:sensorpajen /opt/sensorpajen
|
||||
|
||||
# Set Bluetooth capabilities on Python executable (after ownership change)
|
||||
PYTHON_PATH=$(readlink -f /opt/sensorpajen/venv/bin/python3)
|
||||
if command -v setcap >/dev/null 2>&1; then
|
||||
setcap cap_net_raw,cap_net_admin+eip "$PYTHON_PATH" || {
|
||||
echo "Warning: setcap failed. You may need to run Bluetooth operations as root."
|
||||
echo "Try: sudo setcap cap_net_raw,cap_net_admin+eip $PYTHON_PATH"
|
||||
}
|
||||
else
|
||||
echo "Warning: setcap not found (install libcap2-bin package)"
|
||||
fi
|
||||
|
||||
# Install systemd service file
|
||||
if [ -f /opt/sensorpajen/debian/sensorpajen.service ]; then
|
||||
cp /opt/sensorpajen/debian/sensorpajen.service /etc/systemd/system/
|
||||
elif [ -f /usr/share/doc/sensorpajen/sensorpajen.service ]; then
|
||||
cp /usr/share/doc/sensorpajen/sensorpajen.service /etc/systemd/system/
|
||||
fi
|
||||
|
||||
# Reload systemd
|
||||
systemctl daemon-reload
|
||||
|
||||
# Enable service (but don't start - needs configuration first)
|
||||
systemctl enable sensorpajen.service || {
|
||||
echo "Warning: Could not enable sensorpajen service"
|
||||
}
|
||||
|
||||
# Check if configuration is ready
|
||||
if [ -f /etc/sensorpajen/sensorpajen.env ] && [ -f /etc/sensorpajen/sensors.json ]; then
|
||||
# Check if env file has been configured (not default values)
|
||||
if grep -q "MQTT_HOST=192.168.0.114" /etc/sensorpajen/sensorpajen.env; then
|
||||
echo ""
|
||||
echo "======================================================================"
|
||||
echo " Configuration needed!"
|
||||
echo "======================================================================"
|
||||
echo " Edit /etc/sensorpajen/sensorpajen.env with your MQTT settings"
|
||||
echo " Edit /etc/sensorpajen/sensors.json with your sensor list"
|
||||
echo " Then run: sudo systemctl start sensorpajen"
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
else
|
||||
# Configuration appears to be customized, restart service
|
||||
systemctl restart sensorpajen.service && {
|
||||
echo "Sensorpajen service started"
|
||||
echo "View logs: sudo journalctl -u sensorpajen -f"
|
||||
} || {
|
||||
echo "Failed to start service. Check: sudo systemctl status sensorpajen"
|
||||
}
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "======================================================================"
|
||||
echo " Sensorpajen installed successfully!"
|
||||
echo "======================================================================"
|
||||
echo " Next steps:"
|
||||
echo " 1. Edit /etc/sensorpajen/sensorpajen.env"
|
||||
echo " 2. Edit /etc/sensorpajen/sensors.json"
|
||||
echo " 3. sudo systemctl start sensorpajen"
|
||||
echo " 4. sudo journalctl -u sensorpajen -f"
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
fi
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postinst called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
41
debian/sensorpajen/DEBIAN/postrm
vendored
Executable file
41
debian/sensorpajen/DEBIAN/postrm
vendored
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove)
|
||||
# Service removed but config and user preserved
|
||||
echo "Sensorpajen removed. Configuration preserved in /etc/sensorpajen/"
|
||||
echo "To remove config: sudo rm -rf /etc/sensorpajen/"
|
||||
|
||||
# Remove systemd service file
|
||||
rm -f /etc/systemd/system/sensorpajen.service
|
||||
systemctl daemon-reload || true
|
||||
;;
|
||||
|
||||
purge)
|
||||
# Even on purge, we keep config by default (user can manually delete)
|
||||
# This is safer as it prevents accidental data loss
|
||||
echo "Configuration preserved in /etc/sensorpajen/"
|
||||
echo "To remove config: sudo rm -rf /etc/sensorpajen/"
|
||||
echo "To remove user: sudo userdel sensorpajen"
|
||||
|
||||
# Remove systemd service file
|
||||
rm -f /etc/systemd/system/sensorpajen.service
|
||||
systemctl daemon-reload || true
|
||||
|
||||
# Note: We intentionally do NOT remove:
|
||||
# - /etc/sensorpajen (contains user data)
|
||||
# - sensorpajen user (may own other files/processes)
|
||||
# User must remove these manually if desired
|
||||
;;
|
||||
|
||||
upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postrm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
27
debian/sensorpajen/DEBIAN/prerm
vendored
Executable file
27
debian/sensorpajen/DEBIAN/prerm
vendored
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|upgrade|deconfigure)
|
||||
# Stop service before removal or upgrade
|
||||
if systemctl is-active --quiet sensorpajen.service 2>/dev/null; then
|
||||
echo "Stopping sensorpajen service..."
|
||||
systemctl stop sensorpajen.service || true
|
||||
fi
|
||||
|
||||
# Disable service on removal (not upgrade)
|
||||
if [ "$1" = "remove" ]; then
|
||||
systemctl disable sensorpajen.service || true
|
||||
fi
|
||||
;;
|
||||
|
||||
failed-upgrade)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "prerm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
65
debian/sensorpajen/opt/sensorpajen/pyproject.toml
vendored
Normal file
65
debian/sensorpajen/opt/sensorpajen/pyproject.toml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sensorpajen"
|
||||
version = "2.0.0-dev"
|
||||
description = "Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Fredrik", email = "your@email.com"}
|
||||
]
|
||||
keywords = ["bluetooth", "temperature", "sensor", "mqtt", "raspberry-pi"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Topic :: Home Automation",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"pybluez>=0.31",
|
||||
"bluepy>=1.3.0",
|
||||
"paho-mqtt>=1.6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"black>=23.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/sensorpajen"
|
||||
Repository = "https://github.com/yourusername/sensorpajen"
|
||||
|
||||
[project.scripts]
|
||||
sensorpajen = "sensorpajen.main:main"
|
||||
sensorpajen-approve-sensors = "sensorpajen.approve_sensors:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py39", "py310", "py311"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py39"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
3
debian/sensorpajen/opt/sensorpajen/requirements.txt
vendored
Normal file
3
debian/sensorpajen/opt/sensorpajen/requirements.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
pybluez
|
||||
bluepy
|
||||
paho-mqtt
|
||||
48
debian/sensorpajen/opt/sensorpajen/scripts/approve-sensors.sh
vendored
Executable file
48
debian/sensorpajen/opt/sensorpajen/scripts/approve-sensors.sh
vendored
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Wrapper script for approve-sensors that works in both dev and system mode
|
||||
|
||||
# Detect installation type
|
||||
if [ -d "/opt/sensorpajen" ]; then
|
||||
# System installation
|
||||
PROJECT_ROOT="/opt/sensorpajen"
|
||||
VENV_PATH="/opt/sensorpajen/venv"
|
||||
|
||||
# Load config from system location
|
||||
if [ -f "/etc/sensorpajen/sensorpajen.env" ]; then
|
||||
set -a
|
||||
source /etc/sensorpajen/sensorpajen.env
|
||||
set +a
|
||||
else
|
||||
echo "Warning: /etc/sensorpajen/sensorpajen.env not found"
|
||||
# Set minimal defaults
|
||||
export MQTT_HOST="${MQTT_HOST:-localhost}"
|
||||
export MQTT_PORT="${MQTT_PORT:-1883}"
|
||||
fi
|
||||
else
|
||||
# Development installation
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||
VENV_PATH="$PROJECT_ROOT/.venv"
|
||||
|
||||
# Set minimal required environment variables
|
||||
export MQTT_HOST="${MQTT_HOST:-localhost}"
|
||||
export MQTT_PORT="${MQTT_PORT:-1883}"
|
||||
|
||||
# Load actual config if it exists (will override defaults)
|
||||
if [ -f "$PROJECT_ROOT/config/sensorpajen.env" ]; then
|
||||
set -a
|
||||
source "$PROJECT_ROOT/config/sensorpajen.env"
|
||||
set +a
|
||||
fi
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
if [ -f "$VENV_PATH/bin/activate" ]; then
|
||||
source "$VENV_PATH/bin/activate"
|
||||
else
|
||||
echo "Error: Virtual environment not found at $VENV_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the approve-sensors command
|
||||
python -m sensorpajen.approve_sensors "$@"
|
||||
10
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/__init__.py
vendored
Normal file
10
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/__init__.py
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Sensorpajen - Bluetooth Temperature Sensor Monitor
|
||||
|
||||
Monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors
|
||||
and publishes data to MQTT broker.
|
||||
"""
|
||||
|
||||
__version__ = "2.0.0-dev"
|
||||
__author__ = "Fredrik"
|
||||
__license__ = "MIT"
|
||||
305
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/approve_sensors.py
vendored
Normal file
305
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/approve_sensors.py
vendored
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI tool for approving or ignoring discovered sensors.
|
||||
|
||||
Interactive tool to manage pending and ignored sensors.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from . import config
|
||||
from .discovery_manager import DiscoveryManager, DiscoveredSensor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_metadata_comment(sensor: DiscoveredSensor) -> str:
|
||||
"""
|
||||
Format sensor metadata as a comment string.
|
||||
|
||||
Args:
|
||||
sensor: Discovered sensor
|
||||
|
||||
Returns:
|
||||
Formatted comment string
|
||||
"""
|
||||
return (
|
||||
f"MAC: {sensor.mac}, "
|
||||
f"Name: {sensor.name}, "
|
||||
f"Last seen: {sensor.last_seen}, "
|
||||
f"Temp: {sensor.sample_reading.get('temperature', 'N/A')}°C, "
|
||||
f"Humidity: {sensor.sample_reading.get('humidity', 'N/A')}%, "
|
||||
f"Battery: {sensor.sample_reading.get('battery_percent', 'N/A')}%"
|
||||
)
|
||||
|
||||
|
||||
def display_sensor(sensor: DiscoveredSensor, index: int, total: int):
|
||||
"""
|
||||
Display sensor information to the user.
|
||||
|
||||
Args:
|
||||
sensor: Discovered sensor to display
|
||||
index: Current sensor number (1-based)
|
||||
total: Total number of sensors
|
||||
"""
|
||||
print(f"\n{'='*70}")
|
||||
print(f"Sensor {index}/{total}")
|
||||
print(f"{'='*70}")
|
||||
print(f"MAC Address: {sensor.mac}")
|
||||
print(f"Device Name: {sensor.name}")
|
||||
print(f"Last Seen: {sensor.last_seen}")
|
||||
print(f"Status: {sensor.status}")
|
||||
|
||||
if sensor.status == "ignored" and sensor.ignored_at:
|
||||
print(f"Ignored At: {sensor.ignored_at}")
|
||||
if sensor.ignore_reason:
|
||||
print(f"Reason: {sensor.ignore_reason}")
|
||||
|
||||
# Display sample reading
|
||||
reading = sensor.sample_reading
|
||||
print(f"\nSample Reading:")
|
||||
print(f" Temperature: {reading.get('temperature', 'N/A')}°C")
|
||||
print(f" Humidity: {reading.get('humidity', 'N/A')}%")
|
||||
print(f" Battery: {reading.get('battery_percent', 'N/A')}%")
|
||||
print(f" Voltage: {reading.get('battery_voltage', 'N/A')}mV")
|
||||
print(f"{'='*70}")
|
||||
|
||||
|
||||
def get_user_choice() -> str:
|
||||
"""
|
||||
Get user's choice for what to do with the sensor.
|
||||
|
||||
Returns:
|
||||
User choice: 'a' (approve), 'i' (ignore), 's' (skip)
|
||||
"""
|
||||
while True:
|
||||
choice = input("\n[A]pprove, [I]gnore, [S]kip, [Q]uit? ").strip().lower()
|
||||
if choice in ['a', 'i', 's', 'q']:
|
||||
return choice
|
||||
print("Invalid choice. Please enter A, I, S, or Q.")
|
||||
|
||||
|
||||
def approve_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager):
|
||||
"""
|
||||
Approve a sensor and add it to sensors.json.
|
||||
|
||||
Args:
|
||||
sensor: Sensor to approve
|
||||
manager: Discovery manager
|
||||
"""
|
||||
# Check if sensor already exists in sensors.json
|
||||
sensor_config_path = Path(config.SENSOR_CONFIG_FILE)
|
||||
|
||||
try:
|
||||
with open(sensor_config_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check for duplicates
|
||||
for existing_sensor in data.get('sensors', []):
|
||||
if existing_sensor.get('mac', '').upper() == sensor.mac:
|
||||
print(f"\n⚠️ Sensor {sensor.mac} already exists in sensors.json")
|
||||
print(" Renaming must be done manually in the file.")
|
||||
return
|
||||
|
||||
except FileNotFoundError:
|
||||
# File doesn't exist yet, create with empty sensors list
|
||||
data = {'sensors': []}
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"\n❌ Error: Invalid JSON in {sensor_config_path}: {e}")
|
||||
return
|
||||
|
||||
# Get sensor name from user
|
||||
while True:
|
||||
name = input("\nEnter sensor name (required): ").strip()
|
||||
if name:
|
||||
break
|
||||
print("Sensor name cannot be empty.")
|
||||
|
||||
# Pre-fill comment with metadata
|
||||
default_comment = format_metadata_comment(sensor)
|
||||
print(f"\nDefault comment:")
|
||||
print(f" {default_comment}")
|
||||
|
||||
edit = input("\nEdit comment? [y/N]: ").strip().lower()
|
||||
if edit == 'y':
|
||||
print("\nEnter comment (or press Enter to keep default):")
|
||||
comment = input("> ").strip()
|
||||
if not comment:
|
||||
comment = default_comment
|
||||
else:
|
||||
comment = default_comment
|
||||
|
||||
# Add to sensors.json
|
||||
new_sensor = {
|
||||
"mac": sensor.mac,
|
||||
"name": name
|
||||
}
|
||||
|
||||
if comment:
|
||||
new_sensor["comment"] = comment
|
||||
|
||||
data.setdefault('sensors', []).append(new_sensor)
|
||||
|
||||
try:
|
||||
with open(sensor_config_path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
print(f"\n✅ Sensor approved and added to sensors.json")
|
||||
print(f" Name: {name}")
|
||||
print(f" Configuration will be reloaded automatically within 15 minutes")
|
||||
|
||||
# Mark as approved in discovery manager and save
|
||||
print(f"\nUpdating discovery status...")
|
||||
manager.approve(sensor.mac)
|
||||
print(f"✅ Marked as approved in discovered_sensors.json")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error saving to sensors.json: {e}")
|
||||
|
||||
|
||||
def ignore_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager):
|
||||
"""
|
||||
Ignore a sensor.
|
||||
|
||||
Args:
|
||||
sensor: Sensor to ignore
|
||||
manager: Discovery manager
|
||||
"""
|
||||
reason = input("\nReason for ignoring (optional): ").strip()
|
||||
|
||||
manager.ignore(sensor.mac, reason if reason else None)
|
||||
|
||||
print(f"\n✅ Sensor ignored and marked in discovered_sensors.json")
|
||||
if reason:
|
||||
print(f" Reason: {reason}")
|
||||
|
||||
|
||||
def process_sensors(sensors: List[DiscoveredSensor], manager: DiscoveryManager):
|
||||
"""
|
||||
Process list of sensors interactively.
|
||||
|
||||
Args:
|
||||
sensors: List of sensors to process
|
||||
manager: Discovery manager
|
||||
"""
|
||||
if not sensors:
|
||||
print("\n✅ No sensors to process")
|
||||
return
|
||||
|
||||
print(f"\nFound {len(sensors)} sensor(s) to review")
|
||||
|
||||
for i, sensor in enumerate(sensors, 1):
|
||||
# Mark as reviewed when shown
|
||||
manager.mark_reviewed(sensor.mac)
|
||||
|
||||
display_sensor(sensor, i, len(sensors))
|
||||
|
||||
choice = get_user_choice()
|
||||
|
||||
if choice == 'q':
|
||||
print("\n👋 Exiting...")
|
||||
break
|
||||
elif choice == 'a':
|
||||
approve_sensor(sensor, manager)
|
||||
elif choice == 'i':
|
||||
ignore_sensor(sensor, manager)
|
||||
elif choice == 's':
|
||||
print("\n⏭️ Skipped")
|
||||
continue
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for approve-sensors CLI."""
|
||||
# Parse command line arguments
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Approve or ignore discovered Bluetooth sensors",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s # Show only new pending sensors
|
||||
%(prog)s --all # Show all pending sensors (including reviewed)
|
||||
%(prog)s --ignored # Show only ignored sensors
|
||||
%(prog)s --all --ignored # Show all sensors
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all', '-a',
|
||||
action='store_true',
|
||||
help='Show all pending sensors, including previously reviewed ones'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--ignored', '-i',
|
||||
action='store_true',
|
||||
help='Show ignored sensors'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING,
|
||||
format='%(levelname)s: %(message)s'
|
||||
)
|
||||
|
||||
print("=" * 70)
|
||||
print("Sensorpajen - Approve Sensors")
|
||||
print("=" * 70)
|
||||
|
||||
try:
|
||||
# Load discovery manager
|
||||
manager = DiscoveryManager()
|
||||
|
||||
# Get sensors based on flags
|
||||
if args.all:
|
||||
pending = manager.get_pending()
|
||||
pending_label = "all pending"
|
||||
else:
|
||||
pending = manager.get_new_pending()
|
||||
pending_label = "new pending"
|
||||
|
||||
ignored = manager.get_ignored() if args.ignored else []
|
||||
|
||||
if not pending and not ignored:
|
||||
if args.all or args.ignored:
|
||||
print(f"\n✅ No {pending_label if pending else 'ignored'} sensors found")
|
||||
else:
|
||||
print("\n✅ No new sensors to review")
|
||||
all_pending = manager.get_pending()
|
||||
if all_pending:
|
||||
print(f"\nThere are {len(all_pending)} previously reviewed pending sensor(s).")
|
||||
print("Run with --all to review them again.")
|
||||
return 0
|
||||
|
||||
# Process pending sensors
|
||||
if pending:
|
||||
print(f"\n📋 Processing {len(pending)} {pending_label} sensor(s)...")
|
||||
process_sensors(pending, manager)
|
||||
|
||||
# Process ignored sensors if requested
|
||||
if ignored:
|
||||
if pending:
|
||||
print("\n" + "=" * 70)
|
||||
print(f"\n📋 Processing {len(ignored)} ignored sensor(s)...")
|
||||
process_sensors(ignored, manager)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Done!")
|
||||
print("=" * 70)
|
||||
|
||||
return 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n👋 Interrupted by user")
|
||||
return 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}", exc_info=True)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
160
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/config.py
vendored
Normal file
160
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/config.py
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Configuration management for Sensorpajen.
|
||||
|
||||
Loads configuration from environment variables with sensible defaults.
|
||||
Configuration files are loaded relative to the project root.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Determine project root and config directory
|
||||
# Check if running from system installation (/opt/sensorpajen) or development
|
||||
_opt_sensorpajen_exists = Path('/opt/sensorpajen').exists()
|
||||
_var_lib_exists = Path('/var/lib/sensorpajen').exists()
|
||||
|
||||
if _opt_sensorpajen_exists:
|
||||
# System installation
|
||||
PROJECT_ROOT = Path('/opt/sensorpajen')
|
||||
CONFIG_DIR = Path('/etc/sensorpajen')
|
||||
STATE_DIR = Path('/var/lib/sensorpajen')
|
||||
else:
|
||||
# Development installation (3 levels up from this file: src/sensorpajen/config.py)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
STATE_DIR = CONFIG_DIR
|
||||
|
||||
# MQTT Configuration from environment
|
||||
MQTT_HOST = os.environ.get("MQTT_HOST")
|
||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
MQTT_USER = os.environ.get("MQTT_USER")
|
||||
MQTT_PASSWORD = os.environ.get("MQTT_PASSWORD")
|
||||
MQTT_CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "sensorpajen")
|
||||
MQTT_TOPIC_PREFIX = os.environ.get("MQTT_TOPIC_PREFIX", "MiTemperature2")
|
||||
|
||||
# Validate required MQTT configuration
|
||||
if not MQTT_HOST:
|
||||
raise RuntimeError(
|
||||
"MQTT_HOST environment variable must be set. "
|
||||
"Please configure config/sensorpajen.env"
|
||||
)
|
||||
|
||||
# Sensor configuration file
|
||||
SENSOR_CONFIG_FILE = os.environ.get(
|
||||
"SENSOR_CONFIG_FILE",
|
||||
str(CONFIG_DIR / "sensors.json")
|
||||
)
|
||||
|
||||
# Application settings
|
||||
WATCHDOG_TIMEOUT = int(os.environ.get("WATCHDOG_TIMEOUT", "5"))
|
||||
ENABLE_BATTERY = os.environ.get("ENABLE_BATTERY", "true").lower() == "true"
|
||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
# Bluetooth settings
|
||||
SKIP_IDENTICAL = int(os.environ.get("SKIP_IDENTICAL", "50"))
|
||||
DEBOUNCE = os.environ.get("DEBOUNCE", "true").lower() == "true"
|
||||
|
||||
# ntfy notification settings (optional)
|
||||
NTFY_ENABLED = os.environ.get("NTFY_ENABLED", "false").lower() == "true"
|
||||
NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.sh")
|
||||
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "sensorpajen")
|
||||
NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "")
|
||||
|
||||
# Discovery settings
|
||||
DISCOVERED_SENSORS_FILE = os.environ.get(
|
||||
"DISCOVERED_SENSORS_FILE",
|
||||
str(STATE_DIR / "discovered_sensors.json")
|
||||
)
|
||||
CONFIG_RELOAD_INTERVAL = int(os.environ.get("CONFIG_RELOAD_INTERVAL", "900")) # 15 minutes
|
||||
|
||||
|
||||
class SensorConfig:
|
||||
"""Manages sensor configuration from JSON file."""
|
||||
|
||||
def __init__(self, config_file: str = SENSOR_CONFIG_FILE):
|
||||
"""
|
||||
Initialize sensor configuration.
|
||||
|
||||
Args:
|
||||
config_file: Path to sensors JSON configuration file
|
||||
"""
|
||||
self.config_file = Path(config_file)
|
||||
self.sensors: Dict[str, str] = {}
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""Load sensor configuration from JSON file."""
|
||||
if not self.config_file.exists():
|
||||
logger.warning(
|
||||
f"Sensor configuration file not found: {self.config_file}\n"
|
||||
f"Starting with no sensors - use discovery to add sensors"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Convert sensors list to MAC -> name mapping
|
||||
for sensor in data.get('sensors', []):
|
||||
mac = sensor.get('mac', '').upper()
|
||||
name = sensor.get('name')
|
||||
|
||||
if mac and name:
|
||||
self.sensors[mac] = name
|
||||
logger.debug(f"Loaded sensor: {mac} -> {name}")
|
||||
|
||||
logger.info(f"Loaded {len(self.sensors)} sensors from {self.config_file}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Invalid JSON in {self.config_file}: {e}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error loading sensor config: {e}")
|
||||
|
||||
def get_name(self, mac: str) -> str:
|
||||
"""
|
||||
Get sensor name by MAC address.
|
||||
|
||||
Args:
|
||||
mac: MAC address (any case)
|
||||
|
||||
Returns:
|
||||
Sensor name or the MAC address if not found
|
||||
"""
|
||||
return self.sensors.get(mac.upper(), mac)
|
||||
|
||||
def get_all_macs(self) -> List[str]:
|
||||
"""Get list of all configured MAC addresses."""
|
||||
return list(self.sensors.keys())
|
||||
|
||||
|
||||
def validate_config():
|
||||
"""
|
||||
Validate configuration and log settings.
|
||||
Should be called at application startup.
|
||||
"""
|
||||
install_type = "System" if Path('/opt/sensorpajen').exists() else "Development"
|
||||
logger.info("=== Sensorpajen Configuration ===")
|
||||
logger.info(f"Installation Type: {install_type}")
|
||||
logger.info(f"Project Root: {PROJECT_ROOT}")
|
||||
logger.info(f"Config Directory: {CONFIG_DIR}")
|
||||
logger.info(f"State Directory: {STATE_DIR}")
|
||||
logger.info(f"MQTT Host: {MQTT_HOST}:{MQTT_PORT}")
|
||||
logger.info(f"MQTT User: {MQTT_USER}")
|
||||
logger.info(f"MQTT Client ID: {MQTT_CLIENT_ID}")
|
||||
logger.info(f"MQTT Topic Prefix: {MQTT_TOPIC_PREFIX}")
|
||||
logger.info(f"Sensor Config: {SENSOR_CONFIG_FILE}")
|
||||
logger.info(f"Discovered Sensors: {DISCOVERED_SENSORS_FILE}")
|
||||
logger.info(f"Watchdog Timeout: {WATCHDOG_TIMEOUT}s")
|
||||
logger.info(f"Battery Monitoring: {ENABLE_BATTERY}")
|
||||
logger.info(f"Config Reload Interval: {CONFIG_RELOAD_INTERVAL}s")
|
||||
logger.info(f"ntfy Enabled: {NTFY_ENABLED}")
|
||||
if NTFY_ENABLED:
|
||||
logger.info(f"ntfy URL: {NTFY_URL}/{NTFY_TOPIC}")
|
||||
logger.info(f"Log Level: {LOG_LEVEL}")
|
||||
logger.info("================================")
|
||||
263
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/discovery_manager.py
vendored
Normal file
263
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/discovery_manager.py
vendored
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Discovery manager for tracking and managing discovered sensors.
|
||||
|
||||
Maintains a database of discovered sensors with their metadata and status.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredSensor:
|
||||
"""Represents a discovered sensor with metadata."""
|
||||
mac: str
|
||||
name: str
|
||||
rssi: int
|
||||
first_seen: str
|
||||
last_seen: str
|
||||
sample_reading: Dict[str, float]
|
||||
status: str = "pending" # pending, approved, ignored
|
||||
reviewed: bool = False # Has been shown in approval CLI
|
||||
ignored_at: Optional[str] = None
|
||||
ignore_reason: Optional[str] = None
|
||||
|
||||
|
||||
class DiscoveryManager:
|
||||
"""Manages discovered sensors and their approval status."""
|
||||
|
||||
def __init__(self, discovery_file: str = config.DISCOVERED_SENSORS_FILE):
|
||||
"""
|
||||
Initialize discovery manager.
|
||||
|
||||
Args:
|
||||
discovery_file: Path to discovered sensors JSON file
|
||||
"""
|
||||
self.discovery_file = Path(discovery_file)
|
||||
self.sensors: Dict[str, DiscoveredSensor] = {}
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""Load discovered sensors from JSON file."""
|
||||
if not self.discovery_file.exists():
|
||||
logger.info(f"Creating new discovered sensors file: {self.discovery_file}")
|
||||
self.discovery_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.save()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.discovery_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
for sensor_data in data:
|
||||
sensor = DiscoveredSensor(**sensor_data)
|
||||
self.sensors[sensor.mac.upper()] = sensor
|
||||
|
||||
logger.info(f"Loaded {len(self.sensors)} discovered sensors")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON in {self.discovery_file}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading discovered sensors: {e}")
|
||||
|
||||
def save(self):
|
||||
"""Save discovered sensors to JSON file."""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
self.discovery_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Convert sensors to list of dicts
|
||||
data = [asdict(sensor) for sensor in self.sensors.values()]
|
||||
|
||||
with open(self.discovery_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
logger.debug(f"Saved {len(self.sensors)} discovered sensors")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving discovered sensors: {e}")
|
||||
|
||||
def add_or_update(self, mac: str, name: str, rssi: int,
|
||||
temperature: float, humidity: float,
|
||||
battery_percent: int, battery_voltage: int) -> bool:
|
||||
"""
|
||||
Add or update a discovered sensor.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
name: Advertised device name
|
||||
rssi: Signal strength
|
||||
temperature: Temperature reading
|
||||
humidity: Humidity reading
|
||||
battery_percent: Battery percentage
|
||||
battery_voltage: Battery voltage in mV
|
||||
|
||||
Returns:
|
||||
True if this is a newly discovered sensor, False if updated existing
|
||||
"""
|
||||
mac = mac.upper()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
sample_reading = {
|
||||
"temperature": temperature,
|
||||
"humidity": humidity,
|
||||
"battery_percent": battery_percent,
|
||||
"battery_voltage": battery_voltage
|
||||
}
|
||||
|
||||
if mac in self.sensors:
|
||||
# Update existing sensor
|
||||
sensor = self.sensors[mac]
|
||||
sensor.last_seen = now
|
||||
sensor.rssi = rssi
|
||||
sensor.sample_reading = sample_reading
|
||||
self.save()
|
||||
return False
|
||||
else:
|
||||
# New sensor discovered
|
||||
sensor = DiscoveredSensor(
|
||||
mac=mac,
|
||||
name=name,
|
||||
rssi=rssi,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
sample_reading=sample_reading,
|
||||
status="pending"
|
||||
)
|
||||
self.sensors[mac] = sensor
|
||||
self.save()
|
||||
logger.info(f"New sensor discovered: {mac} ({name})")
|
||||
return True
|
||||
|
||||
def is_known(self, mac: str) -> bool:
|
||||
"""
|
||||
Check if a sensor has been discovered before.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
|
||||
Returns:
|
||||
True if sensor is in discovered list
|
||||
"""
|
||||
return mac.upper() in self.sensors
|
||||
|
||||
def get_status(self, mac: str) -> Optional[str]:
|
||||
"""
|
||||
Get status of a discovered sensor.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
|
||||
Returns:
|
||||
Status string or None if not found
|
||||
"""
|
||||
sensor = self.sensors.get(mac.upper())
|
||||
return sensor.status if sensor else None
|
||||
|
||||
def approve(self, mac: str):
|
||||
"""
|
||||
Mark a sensor as approved.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
"""
|
||||
mac = mac.upper()
|
||||
if mac in self.sensors:
|
||||
self.sensors[mac].status = "approved"
|
||||
self.save()
|
||||
logger.info(f"Sensor approved: {mac}")
|
||||
|
||||
def ignore(self, mac: str, reason: Optional[str] = None):
|
||||
"""
|
||||
Mark a sensor as ignored.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
reason: Optional reason for ignoring
|
||||
"""
|
||||
mac = mac.upper()
|
||||
if mac in self.sensors:
|
||||
self.sensors[mac].status = "ignored"
|
||||
self.sensors[mac].ignored_at = datetime.now().isoformat()
|
||||
self.sensors[mac].ignore_reason = reason
|
||||
self.save()
|
||||
logger.info(f"Sensor ignored: {mac}")
|
||||
|
||||
def get_pending(self) -> List[DiscoveredSensor]:
|
||||
"""Get list of sensors with status 'pending'."""
|
||||
return [s for s in self.sensors.values() if s.status == "pending"]
|
||||
|
||||
def get_new_pending(self) -> List[DiscoveredSensor]:
|
||||
"""Get list of pending sensors that haven't been reviewed yet."""
|
||||
return [s for s in self.sensors.values() if s.status == "pending" and not s.reviewed]
|
||||
|
||||
def get_ignored(self) -> List[DiscoveredSensor]:
|
||||
"""Get list of sensors with status 'ignored'."""
|
||||
return [s for s in self.sensors.values() if s.status == "ignored"]
|
||||
|
||||
def mark_reviewed(self, mac: str):
|
||||
"""
|
||||
Mark a sensor as reviewed (shown in approval CLI).
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
"""
|
||||
mac = mac.upper()
|
||||
if mac in self.sensors:
|
||||
self.sensors[mac].reviewed = True
|
||||
self.save()
|
||||
|
||||
def send_ntfy_notification(self, sensor: DiscoveredSensor):
|
||||
"""
|
||||
Send ntfy notification for a newly discovered sensor.
|
||||
|
||||
Args:
|
||||
sensor: Discovered sensor to notify about
|
||||
"""
|
||||
if not config.NTFY_ENABLED:
|
||||
logger.debug("ntfy notifications disabled")
|
||||
return
|
||||
|
||||
if not config.NTFY_TOKEN:
|
||||
logger.warning("ntfy enabled but NTFY_TOKEN not set")
|
||||
return
|
||||
|
||||
try:
|
||||
message = (
|
||||
f"🆕 New sensor discovered!\n\n"
|
||||
f"MAC: {sensor.mac}\n"
|
||||
f"Name: {sensor.name}\n"
|
||||
f"Last seen: {sensor.last_seen}\n"
|
||||
f"Temp: {sensor.sample_reading.get('temperature', 'N/A')}°C\n"
|
||||
f"Humidity: {sensor.sample_reading.get('humidity', 'N/A')}%\n"
|
||||
f"Battery: {sensor.sample_reading.get('battery_percent', 'N/A')}%\n\n"
|
||||
f"Run 'sensorpajen approve-sensors' to approve or ignore."
|
||||
)
|
||||
|
||||
url = f"{config.NTFY_URL}/{config.NTFY_TOPIC}"
|
||||
|
||||
result = subprocess.run(
|
||||
["curl", "-H", f"Authorization: Bearer {config.NTFY_TOKEN}",
|
||||
"-d", message, url],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Sent ntfy notification for {sensor.mac}")
|
||||
else:
|
||||
logger.warning(f"ntfy notification failed: {result.stderr.decode()}")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("ntfy notification timed out")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending ntfy notification: {e}")
|
||||
226
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/main.py
vendored
Normal file
226
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/main.py
vendored
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sensorpajen - Main entry point
|
||||
|
||||
Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC sensors.
|
||||
Publishes sensor data to MQTT broker.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import signal
|
||||
import logging
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
from . import config
|
||||
from .mqtt_publisher import MQTTPublisher
|
||||
from .sensor_reader import SensorReader, Measurement
|
||||
from .discovery_manager import DiscoveryManager
|
||||
|
||||
|
||||
class Sensorpajen:
|
||||
"""Main application class."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the application."""
|
||||
self.mqtt_publisher: MQTTPublisher = None
|
||||
self.sensor_reader: SensorReader = None
|
||||
self.sensor_config: config.SensorConfig = None
|
||||
self.discovery_manager: DiscoveryManager = None
|
||||
self.running = False
|
||||
self.config_reload_timer: threading.Timer = None
|
||||
|
||||
# Setup logging
|
||||
self._setup_logging()
|
||||
|
||||
# Setup signal handlers
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
|
||||
def _setup_logging(self):
|
||||
"""Configure logging to stdout for journald."""
|
||||
log_level = getattr(logging, config.LOG_LEVEL, logging.INFO)
|
||||
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
stream=sys.stdout
|
||||
)
|
||||
|
||||
# Set our logger
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def _signal_handler(self, sig, frame):
|
||||
"""Handle shutdown signals."""
|
||||
signal_name = "SIGTERM" if sig == signal.SIGTERM else "SIGINT"
|
||||
self.logger.info(f"Received {signal_name}, shutting down gracefully...")
|
||||
self.shutdown()
|
||||
sys.exit(0)
|
||||
|
||||
def _on_measurement(self, measurement: Measurement):
|
||||
"""
|
||||
Callback for new sensor measurements.
|
||||
|
||||
Args:
|
||||
measurement: Sensor measurement data
|
||||
"""
|
||||
try:
|
||||
# Publish to MQTT
|
||||
self.mqtt_publisher.publish_measurement(
|
||||
sensor_name=measurement.sensor_name,
|
||||
temperature=measurement.temperature,
|
||||
humidity=measurement.humidity,
|
||||
battery_voltage=measurement.voltage,
|
||||
battery_level=measurement.battery
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling measurement: {e}")
|
||||
|
||||
def _reload_config(self):
|
||||
"""Reload sensor configuration periodically."""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info("Reloading sensor configuration...")
|
||||
old_sensors = set(self.sensor_config.sensors.keys())
|
||||
self.sensor_config.load()
|
||||
new_sensors = set(self.sensor_config.sensors.keys())
|
||||
|
||||
added = new_sensors - old_sensors
|
||||
removed = old_sensors - new_sensors
|
||||
|
||||
if added:
|
||||
self.logger.info(f"Added sensors: {', '.join(added)}")
|
||||
if removed:
|
||||
self.logger.info(f"Removed sensors: {', '.join(removed)}")
|
||||
if not added and not removed:
|
||||
self.logger.debug("No sensor configuration changes")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reloading configuration: {e}")
|
||||
finally:
|
||||
# Schedule next reload
|
||||
if self.running:
|
||||
self.config_reload_timer = threading.Timer(
|
||||
config.CONFIG_RELOAD_INTERVAL,
|
||||
self._reload_config
|
||||
)
|
||||
self.config_reload_timer.daemon = True
|
||||
self.config_reload_timer.start()
|
||||
|
||||
def start(self):
|
||||
"""Start the application."""
|
||||
try:
|
||||
self.logger.info("=" * 50)
|
||||
self.logger.info(f"Starting Sensorpajen v{__version__}")
|
||||
self.logger.info("=" * 50)
|
||||
|
||||
# Validate and log configuration
|
||||
config.validate_config()
|
||||
|
||||
# Load sensor configuration
|
||||
self.sensor_config = config.SensorConfig()
|
||||
|
||||
if len(self.sensor_config.sensors) == 0:
|
||||
self.logger.warning("No sensors configured")
|
||||
self.logger.warning("Starting in discovery-only mode")
|
||||
self.logger.warning("Use 'sensorpajen approve-sensors' to add sensors")
|
||||
|
||||
# Initialize discovery manager
|
||||
self.logger.info("Initializing discovery manager...")
|
||||
self.discovery_manager = DiscoveryManager()
|
||||
|
||||
# Initialize MQTT publisher
|
||||
self.logger.info("Initializing MQTT publisher...")
|
||||
self.mqtt_publisher = MQTTPublisher()
|
||||
self.mqtt_publisher.connect()
|
||||
|
||||
# Wait a moment for MQTT connection
|
||||
time.sleep(1)
|
||||
|
||||
if not self.mqtt_publisher.is_connected():
|
||||
self.logger.warning("MQTT connection not established yet, continuing anyway...")
|
||||
|
||||
# Initialize sensor reader
|
||||
self.logger.info("Initializing Bluetooth sensor reader...")
|
||||
self.sensor_reader = SensorReader(
|
||||
sensor_config=self.sensor_config,
|
||||
discovery_manager=self.discovery_manager,
|
||||
on_measurement=self._on_measurement,
|
||||
interface=0 # hci0
|
||||
)
|
||||
|
||||
# Start config reload timer
|
||||
self.config_reload_timer = threading.Timer(
|
||||
config.CONFIG_RELOAD_INTERVAL,
|
||||
self._reload_config
|
||||
)
|
||||
self.config_reload_timer.daemon = True
|
||||
self.config_reload_timer.start()
|
||||
self.logger.info(f"Config reload scheduled every {config.CONFIG_RELOAD_INTERVAL}s")
|
||||
|
||||
# Start reading sensors (blocking call)
|
||||
self.logger.info("=" * 50)
|
||||
self.logger.info("Sensorpajen is now running")
|
||||
self.logger.info("Monitoring sensors via Bluetooth...")
|
||||
self.logger.info("Publishing to MQTT...")
|
||||
self.logger.info("Press Ctrl+C to stop")
|
||||
self.logger.info("=" * 50)
|
||||
|
||||
self.running = True
|
||||
self.sensor_reader.start()
|
||||
|
||||
except FileNotFoundError as e:
|
||||
self.logger.error(f"Configuration error: {e}")
|
||||
sys.exit(1)
|
||||
except RuntimeError as e:
|
||||
self.logger.error(f"Configuration error: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start application: {e}", exc_info=True)
|
||||
self.shutdown()
|
||||
sys.exit(1)
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the application gracefully."""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.running = False
|
||||
self.logger.info("Shutting down...")
|
||||
|
||||
# Cancel config reload timer
|
||||
if self.config_reload_timer:
|
||||
try:
|
||||
self.config_reload_timer.cancel()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error canceling reload timer: {e}")
|
||||
|
||||
# Stop sensor reader
|
||||
if self.sensor_reader:
|
||||
try:
|
||||
self.sensor_reader.stop()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping sensor reader: {e}")
|
||||
|
||||
# Disconnect MQTT
|
||||
if self.mqtt_publisher:
|
||||
try:
|
||||
self.mqtt_publisher.disconnect()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error disconnecting MQTT: {e}")
|
||||
|
||||
self.logger.info("Shutdown complete")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
app = Sensorpajen()
|
||||
app.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
131
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/mqtt_publisher.py
vendored
Normal file
131
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/mqtt_publisher.py
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
MQTT Publisher for sensor data.
|
||||
|
||||
Handles connection to MQTT broker and publishing of sensor measurements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import paho.mqtt.client as mqtt
|
||||
from typing import Optional
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MQTTPublisher:
|
||||
"""Manages MQTT connection and publishing of sensor data."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize MQTT publisher with configuration."""
|
||||
self.client: Optional[mqtt.Client] = None
|
||||
self.connected = False
|
||||
self._setup_client()
|
||||
|
||||
def _setup_client(self):
|
||||
"""Setup MQTT client with callbacks."""
|
||||
# Handle both paho-mqtt v1.x and v2.x
|
||||
try:
|
||||
# Try v2.x format (with callback_api_version)
|
||||
self.client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION1,
|
||||
client_id=config.MQTT_CLIENT_ID
|
||||
)
|
||||
except (TypeError, AttributeError):
|
||||
# Fall back to v1.x format
|
||||
self.client = mqtt.Client(config.MQTT_CLIENT_ID)
|
||||
|
||||
# Set credentials if provided
|
||||
if config.MQTT_USER and config.MQTT_PASSWORD:
|
||||
self.client.username_pw_set(config.MQTT_USER, config.MQTT_PASSWORD)
|
||||
|
||||
# Setup callbacks
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
self.client.on_publish = self._on_publish
|
||||
|
||||
logger.info(f"MQTT client configured for {config.MQTT_HOST}:{config.MQTT_PORT}")
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
"""Callback for when client connects to broker."""
|
||||
if rc == 0:
|
||||
self.connected = True
|
||||
logger.info(f"Connected to MQTT broker at {config.MQTT_HOST}:{config.MQTT_PORT}")
|
||||
else:
|
||||
self.connected = False
|
||||
logger.error(f"Failed to connect to MQTT broker. Return code: {rc}")
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc):
|
||||
"""Callback for when client disconnects from broker."""
|
||||
self.connected = False
|
||||
if rc != 0:
|
||||
logger.warning(f"Unexpected disconnection from MQTT broker. Return code: {rc}")
|
||||
else:
|
||||
logger.info("Disconnected from MQTT broker")
|
||||
|
||||
def _on_publish(self, client, userdata, mid):
|
||||
"""Callback for when message is published."""
|
||||
logger.debug(f"Message published: {mid}")
|
||||
|
||||
def connect(self):
|
||||
"""Connect to MQTT broker."""
|
||||
try:
|
||||
logger.info(f"Connecting to MQTT broker at {config.MQTT_HOST}:{config.MQTT_PORT}")
|
||||
self.client.connect(config.MQTT_HOST, config.MQTT_PORT, keepalive=60)
|
||||
self.client.loop_start() # Start network loop in background thread
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||
raise
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from MQTT broker."""
|
||||
if self.client:
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
logger.info("Disconnected from MQTT broker")
|
||||
|
||||
def publish_measurement(self, sensor_name: str, temperature: float,
|
||||
humidity: int, battery_voltage: float = None,
|
||||
battery_level: int = None):
|
||||
"""
|
||||
Publish sensor measurement to MQTT.
|
||||
|
||||
Args:
|
||||
sensor_name: Name of the sensor
|
||||
temperature: Temperature in Celsius
|
||||
humidity: Humidity percentage
|
||||
battery_voltage: Battery voltage (optional)
|
||||
battery_level: Battery level percentage (optional)
|
||||
"""
|
||||
if not self.connected:
|
||||
logger.warning("Not connected to MQTT broker, skipping publish")
|
||||
return
|
||||
|
||||
topic_prefix = f"{config.MQTT_TOPIC_PREFIX}/{sensor_name}"
|
||||
|
||||
try:
|
||||
# Publish temperature
|
||||
self.client.publish(f"{topic_prefix}/temp", f"{temperature:.1f}")
|
||||
logger.debug(f"{sensor_name}: temp={temperature:.1f}°C")
|
||||
|
||||
# Publish humidity
|
||||
self.client.publish(f"{topic_prefix}/humidity", f"{humidity}")
|
||||
logger.debug(f"{sensor_name}: humidity={humidity}%")
|
||||
|
||||
# Publish battery info if enabled and available
|
||||
if config.ENABLE_BATTERY:
|
||||
if battery_voltage is not None:
|
||||
self.client.publish(f"{topic_prefix}/batteryvoltage", f"{battery_voltage:.3f}")
|
||||
logger.debug(f"{sensor_name}: battery_voltage={battery_voltage:.3f}V")
|
||||
|
||||
if battery_level is not None:
|
||||
self.client.publish(f"{topic_prefix}/batterylevel", f"{battery_level}")
|
||||
logger.debug(f"{sensor_name}: battery_level={battery_level}%")
|
||||
|
||||
logger.info(f"Published: {sensor_name} - {temperature:.1f}°C, {humidity}%")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error publishing to MQTT: {e}")
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected to MQTT broker."""
|
||||
return self.connected
|
||||
292
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/sensor_reader.py
vendored
Normal file
292
debian/sensorpajen/opt/sensorpajen/src/sensorpajen/sensor_reader.py
vendored
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Bluetooth sensor reader for Xiaomi Mijia LYWSD03MMC sensors with ATC firmware.
|
||||
|
||||
Reads temperature, humidity, and battery data from BLE advertisements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import threading
|
||||
import bluetooth._bluetooth as bluez
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Callable, Dict
|
||||
|
||||
from . import config
|
||||
from .utils import (enable_le_scan, disable_le_scan,
|
||||
parse_le_advertising_events, raw_packet_to_str, toggle_device)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Measurement:
|
||||
"""Sensor measurement data."""
|
||||
temperature: float
|
||||
humidity: int
|
||||
voltage: float
|
||||
battery: int = 0
|
||||
rssi: int = 0
|
||||
sensor_name: str = ""
|
||||
timestamp: int = 0
|
||||
|
||||
|
||||
class SensorReader:
|
||||
"""Reads Xiaomi LYWSD03MMC sensors with ATC firmware via BLE."""
|
||||
|
||||
def __init__(self, sensor_config: config.SensorConfig,
|
||||
discovery_manager,
|
||||
on_measurement: Callable[[Measurement], None],
|
||||
interface: int = 0):
|
||||
"""
|
||||
Initialize sensor reader.
|
||||
|
||||
Args:
|
||||
sensor_config: Sensor configuration mapping
|
||||
discovery_manager: Discovery manager for tracking new sensors
|
||||
on_measurement: Callback function for new measurements
|
||||
interface: Bluetooth interface number (default 0 for hci0)
|
||||
"""
|
||||
self.sensor_config = sensor_config
|
||||
self.discovery_manager = discovery_manager
|
||||
self.on_measurement = on_measurement
|
||||
self.interface = interface
|
||||
self.sock: Optional[int] = None
|
||||
self.running = False
|
||||
self.last_ble_packet = time.time()
|
||||
self.adv_counter: Dict[str, str] = {} # Track advertisement numbers to avoid duplicates
|
||||
self.watchdog_thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self):
|
||||
"""Start BLE scanning for sensors."""
|
||||
try:
|
||||
logger.info(f"Starting BLE scan on hci{self.interface}")
|
||||
|
||||
# Enable bluetooth device
|
||||
toggle_device(self.interface, True)
|
||||
|
||||
# Open bluetooth socket
|
||||
try:
|
||||
self.sock = bluez.hci_open_dev(self.interface)
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot open bluetooth device hci{self.interface}: {e}")
|
||||
raise
|
||||
|
||||
# Enable LE scanning without filtering duplicates
|
||||
enable_le_scan(self.sock, filter_duplicates=False)
|
||||
|
||||
# Start watchdog if configured
|
||||
if config.WATCHDOG_TIMEOUT > 0:
|
||||
self.running = True
|
||||
self.watchdog_thread = threading.Thread(target=self._watchdog_loop, daemon=True)
|
||||
self.watchdog_thread.start()
|
||||
logger.info(f"Watchdog started with {config.WATCHDOG_TIMEOUT}s timeout")
|
||||
|
||||
logger.info("BLE scanning enabled")
|
||||
logger.info(f"Monitoring {len(self.sensor_config.sensors)} sensors")
|
||||
|
||||
# Start parsing advertisements (blocking call)
|
||||
parse_le_advertising_events(
|
||||
self.sock,
|
||||
handler=self._handle_ble_packet,
|
||||
debug=False
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received keyboard interrupt")
|
||||
self.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sensor reader: {e}")
|
||||
self.stop()
|
||||
raise
|
||||
|
||||
def stop(self):
|
||||
"""Stop BLE scanning."""
|
||||
self.running = False
|
||||
|
||||
if self.sock:
|
||||
try:
|
||||
disable_le_scan(self.sock)
|
||||
logger.info("BLE scanning disabled")
|
||||
except Exception as e:
|
||||
logger.error(f"Error disabling BLE scan: {e}")
|
||||
|
||||
if self.watchdog_thread and self.watchdog_thread.is_alive():
|
||||
self.watchdog_thread.join(timeout=2)
|
||||
|
||||
def _watchdog_loop(self):
|
||||
"""Watchdog thread to restart BLE scanning if no packets received."""
|
||||
restart_counter = 1
|
||||
|
||||
while self.running:
|
||||
time.sleep(1)
|
||||
now = time.time()
|
||||
elapsed = now - self.last_ble_packet
|
||||
|
||||
if elapsed > config.WATCHDOG_TIMEOUT:
|
||||
logger.warning(
|
||||
f"Watchdog: No BLE packet within {int(elapsed)}s. "
|
||||
f"Restarting BLE scan (count: {restart_counter})"
|
||||
)
|
||||
try:
|
||||
disable_le_scan(self.sock)
|
||||
time.sleep(1)
|
||||
enable_le_scan(self.sock, filter_duplicates=False)
|
||||
restart_counter += 1
|
||||
self.last_ble_packet = now # Reset timer
|
||||
except Exception as e:
|
||||
logger.error(f"Error restarting BLE scan: {e}")
|
||||
|
||||
def _handle_ble_packet(self, mac: str, adv_type: int, data: bytes, rssi: int):
|
||||
"""
|
||||
Handle incoming BLE advertisement packet.
|
||||
|
||||
Args:
|
||||
mac: MAC address of the device
|
||||
adv_type: Advertisement type
|
||||
data: Advertisement data
|
||||
rssi: Signal strength
|
||||
"""
|
||||
# Update last packet time for watchdog
|
||||
self.last_ble_packet = time.time()
|
||||
|
||||
# Convert data to hex string
|
||||
data_str = raw_packet_to_str(data)
|
||||
|
||||
# Check if this is an ATC packet
|
||||
# ATC format: [... service UUID 0x181A ... MAC ... data ...]
|
||||
atc_identifier = data_str[6:10].upper()
|
||||
if atc_identifier != "1A18":
|
||||
return # Not an ATC packet
|
||||
|
||||
# Extract MAC from packet and verify it matches
|
||||
packet_mac = data_str[10:22].upper()
|
||||
mac_str = mac.replace(":", "").upper()
|
||||
|
||||
if packet_mac != mac_str:
|
||||
return # MAC mismatch
|
||||
|
||||
mac_with_colons = mac.upper()
|
||||
|
||||
# Parse ATC data packet first to get sensor data
|
||||
try:
|
||||
parsed_data = self._parse_atc_data(data_str)
|
||||
if not parsed_data:
|
||||
return
|
||||
|
||||
temperature, humidity, battery_percent, battery_voltage, adv_number = parsed_data
|
||||
|
||||
# Check if this is a known sensor
|
||||
if mac_with_colons not in self.sensor_config.sensors:
|
||||
# Unknown sensor - check if we should discover it
|
||||
self._handle_unknown_sensor(
|
||||
mac_with_colons,
|
||||
rssi,
|
||||
temperature,
|
||||
humidity,
|
||||
battery_percent,
|
||||
battery_voltage
|
||||
)
|
||||
return
|
||||
|
||||
# Check advertisement number to avoid duplicates
|
||||
if mac_str in self.adv_counter:
|
||||
if self.adv_counter[mac_str] == adv_number:
|
||||
return # Duplicate packet
|
||||
self.adv_counter[mac_str] = adv_number
|
||||
|
||||
# Create measurement for known sensor
|
||||
sensor_name = self.sensor_config.get_name(mac_with_colons)
|
||||
measurement = Measurement(
|
||||
temperature=temperature,
|
||||
humidity=humidity,
|
||||
voltage=battery_voltage / 1000.0,
|
||||
battery=battery_percent,
|
||||
rssi=rssi,
|
||||
sensor_name=sensor_name,
|
||||
timestamp=int(time.time())
|
||||
)
|
||||
|
||||
# Log the measurement
|
||||
logger.info(
|
||||
f"{measurement.sensor_name}: {measurement.temperature}°C, "
|
||||
f"{measurement.humidity}%, {measurement.voltage}V, "
|
||||
f"battery {measurement.battery}%, RSSI {rssi}dBm"
|
||||
)
|
||||
|
||||
# Call measurement callback
|
||||
if self.on_measurement:
|
||||
self.on_measurement(measurement)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing ATC packet from {mac}: {e}")
|
||||
|
||||
def _handle_unknown_sensor(self, mac: str, rssi: int, temperature: float,
|
||||
humidity: int, battery_percent: int, battery_voltage: int):
|
||||
"""
|
||||
Handle discovery of unknown sensor.
|
||||
|
||||
Args:
|
||||
mac: MAC address with colons
|
||||
rssi: Signal strength
|
||||
temperature: Temperature reading
|
||||
humidity: Humidity reading
|
||||
battery_percent: Battery percentage
|
||||
battery_voltage: Battery voltage in mV
|
||||
"""
|
||||
# Get or construct device name from MAC
|
||||
# ATC sensors advertise as ATC_XXXXXX where XXXXXX is last 3 bytes
|
||||
mac_suffix = mac.replace(":", "")[-6:]
|
||||
device_name = f"ATC_{mac_suffix}"
|
||||
|
||||
# Check if already discovered
|
||||
if self.discovery_manager.is_known(mac):
|
||||
# Just update the discovery record
|
||||
self.discovery_manager.add_or_update(
|
||||
mac, device_name, rssi, temperature, humidity,
|
||||
battery_percent, battery_voltage
|
||||
)
|
||||
return
|
||||
|
||||
# New sensor - discover and notify
|
||||
is_new = self.discovery_manager.add_or_update(
|
||||
mac, device_name, rssi, temperature, humidity,
|
||||
battery_percent, battery_voltage
|
||||
)
|
||||
|
||||
if is_new:
|
||||
logger.info(f"New sensor discovered: {mac} ({device_name})")
|
||||
sensor = self.discovery_manager.sensors[mac]
|
||||
self.discovery_manager.send_ntfy_notification(sensor)
|
||||
|
||||
def _parse_atc_data(self, data_str: str) -> Optional[tuple]:
|
||||
"""
|
||||
Parse ATC advertisement data.
|
||||
|
||||
Returns:
|
||||
Tuple of (temperature, humidity, battery_percent, battery_voltage, adv_number) or None
|
||||
"""
|
||||
try:
|
||||
# Temperature: bytes 22-26, signed int16, big endian, /10
|
||||
temp_hex = data_str[22:26]
|
||||
temp_raw = int(temp_hex, 16)
|
||||
if temp_raw & 0x8000: # Check sign bit
|
||||
temp_raw = temp_raw - 0x10000
|
||||
temperature = temp_raw / 10.0
|
||||
|
||||
# Humidity: bytes 26-28, uint8
|
||||
humidity = int(data_str[26:28], 16)
|
||||
|
||||
# Battery: bytes 28-30, uint8
|
||||
battery_percent = int(data_str[28:30], 16)
|
||||
|
||||
# Battery voltage: bytes 30-34, uint16, big endian, mV
|
||||
battery_voltage = int(data_str[30:34], 16)
|
||||
|
||||
# Advertisement number: last 2 bytes
|
||||
adv_number = data_str[-2:]
|
||||
|
||||
return (temperature, humidity, battery_percent, battery_voltage, adv_number)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"Error parsing ATC data: {e}")
|
||||
return None
|
||||
32
debian/sensorpajen/usr/lib/systemd/system/sensorpajen.service
vendored
Normal file
32
debian/sensorpajen/usr/lib/systemd/system/sensorpajen.service
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=Sensorpajen - Bluetooth Temperature Sensor Monitor
|
||||
Documentation=https://github.com/yourusername/sensorpajen
|
||||
After=network.target bluetooth.target
|
||||
Wants=bluetooth.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sensorpajen
|
||||
Group=sensorpajen
|
||||
WorkingDirectory=/opt/sensorpajen
|
||||
EnvironmentFile=/etc/sensorpajen/sensorpajen.env
|
||||
ExecStart=/opt/sensorpajen/venv/bin/python -m sensorpajen.main
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Bluetooth capabilities require this to be false
|
||||
NoNewPrivileges=false
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=sensorpajen
|
||||
|
||||
# Security hardening (where possible with Bluetooth requirements)
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/etc/sensorpajen /var/lib/sensorpajen
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
BIN
debian/sensorpajen/usr/share/doc/sensorpajen/INSTALL.md.gz
vendored
Normal file
BIN
debian/sensorpajen/usr/share/doc/sensorpajen/INSTALL.md.gz
vendored
Normal file
Binary file not shown.
BIN
debian/sensorpajen/usr/share/doc/sensorpajen/ROADMAP.md.gz
vendored
Normal file
BIN
debian/sensorpajen/usr/share/doc/sensorpajen/ROADMAP.md.gz
vendored
Normal file
Binary file not shown.
BIN
debian/sensorpajen/usr/share/doc/sensorpajen/changelog.Debian.gz
vendored
Normal file
BIN
debian/sensorpajen/usr/share/doc/sensorpajen/changelog.Debian.gz
vendored
Normal file
Binary file not shown.
32
debian/sensorpajen/usr/share/doc/sensorpajen/examples/discovered_sensors.json.example
vendored
Normal file
32
debian/sensorpajen/usr/share/doc/sensorpajen/examples/discovered_sensors.json.example
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"mac": "A4:C1:38:12:34:56",
|
||||
"name": "ATC_123456",
|
||||
"rssi": -65,
|
||||
"first_seen": "2025-12-27T10:30:15",
|
||||
"last_seen": "2025-12-27T10:35:42",
|
||||
"sample_reading": {
|
||||
"temperature": 21.5,
|
||||
"humidity": 45,
|
||||
"battery_percent": 87,
|
||||
"battery_voltage": 2950
|
||||
},
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:AB:CD:EF",
|
||||
"name": "ATC_ABCDEF",
|
||||
"rssi": -72,
|
||||
"first_seen": "2025-12-27T11:00:00",
|
||||
"last_seen": "2025-12-27T11:10:00",
|
||||
"sample_reading": {
|
||||
"temperature": 19.8,
|
||||
"humidity": 52,
|
||||
"battery_percent": 65,
|
||||
"battery_voltage": 2800
|
||||
},
|
||||
"status": "ignored",
|
||||
"ignored_at": "2025-12-27T11:15:00",
|
||||
"ignore_reason": "Test sensor, not needed"
|
||||
}
|
||||
]
|
||||
29
debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensorpajen.env.example
vendored
Normal file
29
debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensorpajen.env.example
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# MQTT Configuration
|
||||
MQTT_HOST=192.168.0.114
|
||||
MQTT_PORT=1883
|
||||
MQTT_USER=hasse
|
||||
MQTT_PASSWORD=casablanca
|
||||
MQTT_CLIENT_ID=mibridge
|
||||
|
||||
# Sensor Configuration
|
||||
# For system installation (/opt/sensorpajen): Use absolute paths
|
||||
# SENSOR_CONFIG_FILE=/etc/sensorpajen/sensors.json
|
||||
# DISCOVERED_SENSORS_FILE=/etc/sensorpajen/discovered_sensors.json
|
||||
#
|
||||
# For development installation: Use relative paths (from project root)
|
||||
# SENSOR_CONFIG_FILE=config/sensors.json
|
||||
# DISCOVERED_SENSORS_FILE=config/discovered_sensors.json
|
||||
#
|
||||
# If not set, defaults will be used based on installation type
|
||||
|
||||
# Application Settings
|
||||
WATCHDOG_TIMEOUT=5
|
||||
ENABLE_BATTERY=true
|
||||
LOG_LEVEL=INFO
|
||||
CONFIG_RELOAD_INTERVAL=900
|
||||
|
||||
# ntfy Notifications (optional)
|
||||
NTFY_ENABLED=false
|
||||
NTFY_URL=https://ntfy.sh
|
||||
NTFY_TOPIC=sensorpajen
|
||||
NTFY_TOKEN=
|
||||
37
debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensors.json.example
vendored
Normal file
37
debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensors.json.example
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"sensors": [
|
||||
{
|
||||
"mac": "A4:C1:38:98:7B:B6",
|
||||
"name": "mi_temp_1",
|
||||
"comment": "Example sensor - replace with your sensors"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:29:03:0D",
|
||||
"name": "mi_temp_2"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:62:CA:83",
|
||||
"name": "mi_temp_3"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:D5:EA:63",
|
||||
"name": "mi_temp_4"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:7C:9C:63",
|
||||
"name": "mi_temp_5"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:68:2C:DA",
|
||||
"name": "mi_temp_6"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:AD:74:2B",
|
||||
"name": "mi_temp_7"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:46:9F:D1",
|
||||
"name": "mi_temp_8"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
debian/sensorpajen/usr/share/doc/sensorpajen/readme.md.gz
vendored
Normal file
BIN
debian/sensorpajen/usr/share/doc/sensorpajen/readme.md.gz
vendored
Normal file
Binary file not shown.
65
pyproject.toml
Normal file
65
pyproject.toml
Normal file
@@ -0,0 +1,65 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sensorpajen"
|
||||
version = "2.0.0"
|
||||
description = "Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Fredrik", email = "your@email.com"}
|
||||
]
|
||||
keywords = ["bluetooth", "temperature", "sensor", "mqtt", "raspberry-pi"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Topic :: Home Automation",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"pybluez>=0.31",
|
||||
"bluepy>=1.3.0",
|
||||
"paho-mqtt>=1.6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"black>=23.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/sensorpajen"
|
||||
Repository = "https://github.com/yourusername/sensorpajen"
|
||||
|
||||
[project.scripts]
|
||||
sensorpajen = "sensorpajen.main:main"
|
||||
sensorpajen-approve-sensors = "sensorpajen.approve_sensors:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py39", "py310", "py311"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py39"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
276
readme.md
276
readme.md
@@ -1,33 +1,257 @@
|
||||
# Sensorpajen
|
||||
Raspberry Pi Zero W som läser av diverse mätere och rapporterar data till MQTT.
|
||||
Använder Xiaomi Mijia thermometer 2 samt inbyggd DHT11 sensor
|
||||
|
||||
## Bluetooth termometrarna
|
||||
- LYWSD03MMC.py - script för att läsa av Mi Mijia Thermometer 2
|
||||
- sensorer.ini - config till LYWSD03MMC
|
||||
- sendToMQTT.sh - stödscript till LYWSD03MMC
|
||||
- bluetooth_utils.py - stödscript till LYWSD03MMC
|
||||
Raspberry Pi service that monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors and publishes data to MQTT.
|
||||
|
||||
## Sensorer i pajen
|
||||
- temperatur_koksfonstret.py - Läser av DHT 11 sensor kopplad till Pi, används av cron
|
||||
- sensorer.sh - script för att starta tmux vid boot, används av cron
|
||||
|
||||
## Cronjobb
|
||||
```
|
||||
@reboot /home/pi/sensorer.sh
|
||||
*/1 * * * * /home/pi/temperatur_koksfonstret.py 11 4
|
||||
## Features
|
||||
|
||||
- 🌡️ Monitors 8 Xiaomi Mijia thermometers via Bluetooth (ATC firmware)
|
||||
- 📡 Publishes temperature, humidity, and battery data to MQTT
|
||||
- 🔄 Automatic restart on failure
|
||||
- 📊 Systemd service with journald logging
|
||||
- 🔧 Modern Python package with virtual environment
|
||||
- ⚙️ Configuration via environment variables and JSON
|
||||
|
||||
## Requirements
|
||||
|
||||
- Raspberry Pi (tested on Raspberry Pi Zero W and newer)
|
||||
- Raspberry Pi OS (Debian-based)
|
||||
- Python 3.9+
|
||||
- Bluetooth adapter
|
||||
- MQTT broker
|
||||
- Xiaomi Mijia LYWSD03MMC sensors with ATC firmware
|
||||
|
||||
## Installation
|
||||
|
||||
### System Installation (Debian Package - Recommended for Raspberry Pi)
|
||||
|
||||
The easiest way to install on Raspberry Pi OS is using the pre-built Debian package:
|
||||
|
||||
```bash
|
||||
# Download the latest release
|
||||
wget https://gitea.wahlberg.se/api/v1/repos/fredrik/sensorpajen/releases/download/v2.0.0/sensorpajen_2.0.0_all.deb
|
||||
|
||||
# Install
|
||||
sudo dpkg -i sensorpajen_2.0.0_all.deb
|
||||
|
||||
# Configure
|
||||
sudo nano /etc/sensorpajen/sensorpajen.env # Edit MQTT settings
|
||||
sudo systemctl restart sensorpajen
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u sensorpajen -f
|
||||
```
|
||||
|
||||
## Flasha nya termometrar
|
||||
The system package installs:
|
||||
- Application in `/opt/sensorpajen/`
|
||||
- Configuration in `/etc/sensorpajen/`
|
||||
- Runtime state in `/var/lib/sensorpajen/`
|
||||
- Systemd service (runs automatically)
|
||||
|
||||
- Öppna och flasha bara en termometer i taget!!
|
||||
- Ha en permanent marker tillgänglig
|
||||
- Flashning måste göras från en mobil
|
||||
### Development Installation
|
||||
|
||||
1. Ta reda på termometerns mac-adress med någon bra app för Bluetooth BLE . Den heter något i stil med LYWSD03MMC (Om du bara startat en termometer så är det lätt att hitta)
|
||||
2. Ladda ned senaste releasen av firmware här: https://github.com/atc1441/ATC_MiThermometer
|
||||
3. Öppna den här webbsidan: https://atc1441.github.io/TelinkFlasher.html
|
||||
4. Tryck på Connect och sök upp den aktiva termometern. Den heter något i stil med LYWSD03MMC
|
||||
5. Gör Do Activation och när den hittat Select Firmarware Start Flashing
|
||||
6. Anteckna mac-adressen från (1) och sätt ett id på termometern. Det ska senare in i filen sensorer.ini
|
||||
7. Verifiera i din BLE läsare att termometern nu heter något i stil med ATC_XXXXXX. Om du missade att anteckna mac-adressen så är första delen alltid A4:C1:38 och XXXXXX i namnet är de sista tre delarna
|
||||
See [INSTALL.md](INSTALL.md) for complete development setup.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Quick Setup
|
||||
|
||||
After installation, configure your MQTT broker and sensors:
|
||||
|
||||
```bash
|
||||
# Edit MQTT settings
|
||||
sudo nano /etc/sensorpajen/sensorpajen.env
|
||||
|
||||
# Restart service to apply changes
|
||||
sudo systemctl restart sensorpajen
|
||||
```
|
||||
|
||||
### Approving Sensors (Discovery Workflow)
|
||||
|
||||
The service automatically discovers nearby Bluetooth sensors and stores them in a pending list. You approve which ones to monitor:
|
||||
|
||||
```bash
|
||||
# Start sensor discovery (if not already running)
|
||||
sudo systemctl start sensorpajen
|
||||
|
||||
# Let it scan for a minute or two to discover sensors
|
||||
sleep 120
|
||||
|
||||
# View discovered sensors and approve them
|
||||
sudo sensorpajen approve-sensors
|
||||
```
|
||||
|
||||
The approval CLI will:
|
||||
1. Show newly discovered sensors with their current readings
|
||||
2. Ask you to approve, ignore, or skip each sensor
|
||||
3. Save approved sensors to `/etc/sensorpajen/sensors.json`
|
||||
4. Mark their status in `/var/lib/sensorpajen/discovered_sensors.json`
|
||||
|
||||
When you approve a sensor, it's added to your configuration and the service automatically starts monitoring it.
|
||||
|
||||
### MQTT Settings
|
||||
|
||||
Edit `config/sensorpajen.env`:
|
||||
|
||||
```bash
|
||||
MQTT_HOST=192.168.1.10
|
||||
MQTT_PORT=1883
|
||||
MQTT_USER=username
|
||||
MQTT_PASSWORD=password
|
||||
MQTT_CLIENT_ID=sensorpajen
|
||||
MQTT_TOPIC_PREFIX=MiTemperature2
|
||||
```
|
||||
|
||||
### Sensors
|
||||
|
||||
Sensors are automatically managed via the approval workflow. You can also manually edit `/etc/sensorpajen/sensors.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"sensors": [
|
||||
{
|
||||
"mac": "A4:C1:38:12:34:56",
|
||||
"name": "Living Room"
|
||||
},
|
||||
{
|
||||
"mac": "A4:C1:38:AB:CD:EF",
|
||||
"name": "Bedroom"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## Service Management
|
||||
|
||||
### System Installation (Debian Package)
|
||||
|
||||
```bash
|
||||
# Start/stop service
|
||||
sudo systemctl start sensorpajen
|
||||
sudo systemctl stop sensorpajen
|
||||
|
||||
# Enable/disable autostart
|
||||
sudo systemctl enable sensorpajen
|
||||
sudo systemctl disable sensorpajen
|
||||
|
||||
# View status
|
||||
sudo systemctl status sensorpajen
|
||||
|
||||
# View logs (live)
|
||||
sudo journalctl -u sensorpajen -f
|
||||
|
||||
# View last 50 log lines
|
||||
sudo journalctl -u sensorpajen -n 50
|
||||
|
||||
# Uninstall
|
||||
sudo dpkg -r sensorpajen
|
||||
# Note: Configuration is preserved in /etc/sensorpajen/
|
||||
# To remove config: sudo rm -rf /etc/sensorpajen/
|
||||
```
|
||||
|
||||
### Development Installation
|
||||
|
||||
## Flashing New Thermometers
|
||||
|
||||
**Important**: Flash only one thermometer at a time!
|
||||
|
||||
1. **Find MAC address**: Use a Bluetooth BLE app to find the thermometer's MAC address. It will appear as `LYWSD03MMC`.
|
||||
|
||||
2. **Download firmware**: Get the latest ATC firmware from https://github.com/atc1441/ATC_MiThermometer
|
||||
|
||||
3. **Flash firmware**:
|
||||
- Open https://atc1441.github.io/TelinkFlasher.html on your phone
|
||||
- Click "Connect" and select your thermometer (LYWSD03MMC)
|
||||
- Click "Do Activation"
|
||||
- When found, select firmware and click "Start Flashing"
|
||||
|
||||
4. **Record MAC address**: Note the MAC address (from step 1) and label the thermometer physically with a permanent marker.
|
||||
|
||||
5. **Add to configuration**: Add the MAC address and name to `config/sensors.json`
|
||||
|
||||
6. **Verify**: The thermometer should now appear as `ATC_XXXXXX` where XXXXXX are the last 3 bytes of the MAC address. MAC addresses always start with `A4:C1:38`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
sensorpajen/
|
||||
├── src/sensorpajen/ # Python package
|
||||
│ ├── main.py # Application entry point
|
||||
│ ├── config.py # Configuration management
|
||||
│ ├── mqtt_publisher.py # MQTT client wrapper
|
||||
│ ├── sensor_reader.py # Bluetooth sensor reader
|
||||
│ └── utils.py # Bluetooth utilities
|
||||
├── config/ # Configuration files
|
||||
│ ├── sensorpajen.env.example
|
||||
│ └── sensors.json.example
|
||||
├── systemd/ # Systemd service files
|
||||
│ ├── sensorpajen.service
|
||||
│ └── README.md
|
||||
├── legacy/ # Old scripts (deprecated)
|
||||
├── pyproject.toml # Python package configuration
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### System Installation (Debian Package)
|
||||
|
||||
**Service won't start:**
|
||||
```bash
|
||||
# Check what's wrong
|
||||
sudo journalctl -u sensorpajen -n 50
|
||||
|
||||
# Check configuration is valid
|
||||
sudo cat /etc/sensorpajen/sensorpajen.env
|
||||
|
||||
# Manually test the application
|
||||
sudo /opt/sensorpajen/venv/bin/python -m sensorpajen.main
|
||||
```
|
||||
|
||||
**MQTT connection issues:**
|
||||
```bash
|
||||
# Verify MQTT settings in the log
|
||||
sudo journalctl -u sensorpajen | grep MQTT
|
||||
|
||||
# Test MQTT connection manually
|
||||
mosquitto_sub -h <MQTT_HOST> -u <USER> -P <PASSWORD> -t "MiTemperature2/#" -v
|
||||
```
|
||||
|
||||
**Sensor not found:**
|
||||
```bash
|
||||
# Run sensor discovery
|
||||
sudo sensorpajen approve-sensors
|
||||
|
||||
# Check discovered sensors
|
||||
sudo cat /var/lib/sensorpajen/discovered_sensors.json | jq '.'
|
||||
```
|
||||
|
||||
### Development Installation
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone <repo-url> ~/sensorpajen
|
||||
cd ~/sensorpajen
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
|
||||
# Run directly (without systemd)
|
||||
python -m sensorpajen.main
|
||||
|
||||
# Run tests (when available)
|
||||
pytest
|
||||
```
|
||||
|
||||
## Migration from Legacy System
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for the complete migration plan from the old cron/tmux-based system to the modern systemd service.
|
||||
|
||||
## License
|
||||
|
||||
See license headers in individual source files.
|
||||
|
||||
## Credits
|
||||
|
||||
- Bluetooth utilities based on work by Colin GUYON (MIT License)
|
||||
- ATC firmware by atc1441: https://github.com/atc1441/ATC_MiThermometer
|
||||
@@ -1,2 +1,3 @@
|
||||
pybluez
|
||||
bluepy
|
||||
paho-mqtt
|
||||
|
||||
48
scripts/approve-sensors.sh
Executable file
48
scripts/approve-sensors.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Wrapper script for approve-sensors that works in both dev and system mode
|
||||
|
||||
# Detect installation type
|
||||
if [ -d "/opt/sensorpajen" ]; then
|
||||
# System installation
|
||||
PROJECT_ROOT="/opt/sensorpajen"
|
||||
VENV_PATH="/opt/sensorpajen/venv"
|
||||
|
||||
# Load config from system location
|
||||
if [ -f "/etc/sensorpajen/sensorpajen.env" ]; then
|
||||
set -a
|
||||
source /etc/sensorpajen/sensorpajen.env
|
||||
set +a
|
||||
else
|
||||
echo "Warning: /etc/sensorpajen/sensorpajen.env not found"
|
||||
# Set minimal defaults
|
||||
export MQTT_HOST="${MQTT_HOST:-localhost}"
|
||||
export MQTT_PORT="${MQTT_PORT:-1883}"
|
||||
fi
|
||||
else
|
||||
# Development installation
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||
VENV_PATH="$PROJECT_ROOT/.venv"
|
||||
|
||||
# Set minimal required environment variables
|
||||
export MQTT_HOST="${MQTT_HOST:-localhost}"
|
||||
export MQTT_PORT="${MQTT_PORT:-1883}"
|
||||
|
||||
# Load actual config if it exists (will override defaults)
|
||||
if [ -f "$PROJECT_ROOT/config/sensorpajen.env" ]; then
|
||||
set -a
|
||||
source "$PROJECT_ROOT/config/sensorpajen.env"
|
||||
set +a
|
||||
fi
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
if [ -f "$VENV_PATH/bin/activate" ]; then
|
||||
source "$VENV_PATH/bin/activate"
|
||||
else
|
||||
echo "Error: Virtual environment not found at $VENV_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the approve-sensors command
|
||||
python -m sensorpajen.approve_sensors "$@"
|
||||
184
scripts/verify-deb.sh
Executable file
184
scripts/verify-deb.sh
Executable file
@@ -0,0 +1,184 @@
|
||||
#!/bin/bash
|
||||
# Automated verification script for Debian package
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "======================================================================"
|
||||
echo " Sensorpajen Debian Package Verification"
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
|
||||
# Check dependencies
|
||||
echo -n "Checking for dpkg-deb... "
|
||||
if command -v dpkg-deb >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}OK${NC}"
|
||||
else
|
||||
echo -e "${RED}MISSING${NC}"
|
||||
echo "Install with: sudo apt install dpkg-dev"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "Checking for lintian... "
|
||||
if command -v lintian >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}OK${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}MISSING${NC}"
|
||||
echo "Install with: sudo apt install lintian"
|
||||
echo "Continuing without lintian checks..."
|
||||
SKIP_LINTIAN=1
|
||||
fi
|
||||
|
||||
echo -n "Checking for debhelper... "
|
||||
if dpkg -l debhelper >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}OK${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}MISSING${NC}"
|
||||
echo "Install with: sudo apt install debhelper"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Get project root
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Check required files exist
|
||||
echo "Checking required files..."
|
||||
REQUIRED_FILES=(
|
||||
"debian/control"
|
||||
"debian/rules"
|
||||
"debian/install"
|
||||
"debian/changelog"
|
||||
"debian/postinst"
|
||||
"debian/prerm"
|
||||
"debian/postrm"
|
||||
"debian/sensorpajen.service"
|
||||
"src/sensorpajen/main.py"
|
||||
"pyproject.toml"
|
||||
)
|
||||
|
||||
# Optional files (debian/compat is now optional - use Build-Depends instead)
|
||||
OPTIONAL_FILES=(
|
||||
"debian/compat"
|
||||
)
|
||||
|
||||
ALL_FILES_OK=1
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
echo -n " $file... "
|
||||
if [ -f "$file" ]; then
|
||||
echo -e "${GREEN}OK${NC}"
|
||||
else
|
||||
echo -e "${RED}MISSING${NC}"
|
||||
ALL_FILES_OK=0
|
||||
fi
|
||||
done
|
||||
|
||||
# Check optional files
|
||||
for file in "${OPTIONAL_FILES[@]}"; do
|
||||
echo -n " $file... "
|
||||
if [ -f "$file" ]; then
|
||||
echo -e "${GREEN}OK${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}OPTIONAL${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $ALL_FILES_OK -eq 0 ]; then
|
||||
echo -e "${RED}Some required files are missing!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Extract version from pyproject.toml
|
||||
VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "Package version: $VERSION"
|
||||
echo ""
|
||||
|
||||
# Clean previous builds
|
||||
echo "Cleaning previous builds..."
|
||||
rm -f ../*.deb ../*.build ../*.buildinfo ../*.changes
|
||||
rm -rf debian/.debhelper debian/sensorpajen debian/files
|
||||
|
||||
# Build the package with gzip compression (for compatibility)
|
||||
echo "Building Debian package..."
|
||||
echo "======================================================================"
|
||||
dpkg-buildpackage -us -uc -b -Zgzip
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}Build failed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Build successful!${NC}"
|
||||
echo ""
|
||||
|
||||
# Find the built package
|
||||
DEB_FILE=$(ls -t ../*.deb 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$DEB_FILE" ]; then
|
||||
echo -e "${RED}No .deb file found!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Package: $DEB_FILE"
|
||||
echo ""
|
||||
|
||||
# Show package contents
|
||||
echo "Package contents:"
|
||||
echo "======================================================================"
|
||||
dpkg-deb -c "$DEB_FILE" | head -20
|
||||
TOTAL_FILES=$(dpkg-deb -c "$DEB_FILE" | wc -l)
|
||||
if [ $TOTAL_FILES -gt 20 ]; then
|
||||
echo "... and $(($TOTAL_FILES - 20)) more files"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Show package info
|
||||
echo "Package information:"
|
||||
echo "======================================================================"
|
||||
dpkg-deb -I "$DEB_FILE"
|
||||
echo ""
|
||||
|
||||
# Run lintian if available
|
||||
if [ -z "$SKIP_LINTIAN" ]; then
|
||||
echo "Running lintian checks..."
|
||||
echo "======================================================================"
|
||||
|
||||
# Run lintian - allow warnings but fail on errors
|
||||
if lintian "$DEB_FILE"; then
|
||||
echo -e "${GREEN}Lintian passed!${NC}"
|
||||
else
|
||||
LINTIAN_EXIT=$?
|
||||
echo -e "${YELLOW}Lintian found issues (exit code: $LINTIAN_EXIT)${NC}"
|
||||
echo "Review the output above. Warnings are acceptable, errors should be fixed."
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Final summary
|
||||
echo "======================================================================"
|
||||
echo -e "${GREEN}Package verification complete!${NC}"
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
echo "Package location: $DEB_FILE"
|
||||
echo ""
|
||||
echo "To install on a Raspberry Pi:"
|
||||
echo " scp $DEB_FILE pi@raspberrypi:~/"
|
||||
echo " ssh pi@raspberrypi"
|
||||
echo " sudo apt install ./$(basename $DEB_FILE)"
|
||||
echo ""
|
||||
echo "To test locally (not recommended, will modify /opt and /etc):"
|
||||
echo " sudo apt install $DEB_FILE"
|
||||
echo ""
|
||||
|
||||
exit 0
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
#This script is provided by Chiunownow https://github.com/Chiunownow
|
||||
#Thank you very much for providing this script
|
||||
#This script is
|
||||
|
||||
#use e.g with that script: MySensor.sh
|
||||
#!/bin/bash
|
||||
#DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
#$DIR/LYWSD03MMC.py -d <device> -b 1000 -r --debounce --skipidentical 50 --name MySensor --callback sendToMQTT
|
||||
|
||||
#mosquitto_pub -h mqtt.host -t "MiTemperature2/$2/temp" -u mqtt.username -P mqtt.passwd -i "mibridge" -m "$3"
|
||||
#mosquitto_pub -h mqtt.host -t "MiTemperature2/$2/humidity" -u mqtt.username -P mqtt.passwd -i "mibridge" -m "$4"
|
||||
#mosquitto_pub -h mqtt.host -t "MiTemperature2/$2/batteryvoltage" -u mqtt.username -P mqtt.passwd -i "mibridge" -m "$5"
|
||||
#mosquitto_pub -h mqtt.host -t "MiTemperature2/$2/batterylevel" -u mqtt.username -P mqtt.passwd -i "mibridge" -m "$6"
|
||||
|
||||
mosquitto_pub -h 192.168.0.114 -t "MiTemperature2/$2/temp" -u hasse -P casablanca -i "mibridge" -m "$3"
|
||||
mosquitto_pub -h 192.168.0.114 -t "MiTemperature2/$2/humidity" -u hasse -P casablanca -i "mibridge" -m "$4"
|
||||
mosquitto_pub -h 192.168.0.114 -t "MiTemperature2/$2/batteryvoltage" -u hasse -P casablanca -i "mibridge" -m "$5"
|
||||
mosquitto_pub -h 192.168.0.114 -t "MiTemperature2/$2/batterylevel" -u hasse -P casablanca -i "mibridge" -m "$6"
|
||||
26
sensorer.ini
26
sensorer.ini
@@ -1,26 +0,0 @@
|
||||
[A4:C1:38:98:7B:B6]
|
||||
sensorname=mi_temp_1
|
||||
|
||||
[A4:C1:38:29:03:0D]
|
||||
sensorname=mi_temp_2
|
||||
|
||||
[A4:C1:38:62:CA:83]
|
||||
sensorname=mi_temp_3
|
||||
|
||||
[A4:C1:38:D5:EA:63]
|
||||
sensorname=mi_temp_4
|
||||
|
||||
[A4:C1:38:7C:9C:63]
|
||||
sensorname=mi_temp_5
|
||||
|
||||
[A4:C1:38:68:2C:DA]
|
||||
sensorname=mi_temp_6
|
||||
|
||||
[A4:C1:38:AD:74:2B]
|
||||
sensorname=mi_temp_7
|
||||
|
||||
[A4:C1:38:46:9F:D1]
|
||||
sensorname=mi_temp_8
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
# körs vid boot
|
||||
|
||||
tmux new-session -d -s sensorer '/home/pi/sensorpajen/LYWSD03MMC.py -a -wdt 5 --devicelistfile sensorer.ini --callback yes --battery'
|
||||
tmux detach -s sensorer
|
||||
10
src/sensorpajen/__init__.py
Normal file
10
src/sensorpajen/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Sensorpajen - Bluetooth Temperature Sensor Monitor
|
||||
|
||||
Monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors
|
||||
and publishes data to MQTT broker.
|
||||
"""
|
||||
|
||||
__version__ = "2.0.0-dev"
|
||||
__author__ = "Fredrik"
|
||||
__license__ = "MIT"
|
||||
305
src/sensorpajen/approve_sensors.py
Normal file
305
src/sensorpajen/approve_sensors.py
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI tool for approving or ignoring discovered sensors.
|
||||
|
||||
Interactive tool to manage pending and ignored sensors.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from . import config
|
||||
from .discovery_manager import DiscoveryManager, DiscoveredSensor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_metadata_comment(sensor: DiscoveredSensor) -> str:
|
||||
"""
|
||||
Format sensor metadata as a comment string.
|
||||
|
||||
Args:
|
||||
sensor: Discovered sensor
|
||||
|
||||
Returns:
|
||||
Formatted comment string
|
||||
"""
|
||||
return (
|
||||
f"MAC: {sensor.mac}, "
|
||||
f"Name: {sensor.name}, "
|
||||
f"Last seen: {sensor.last_seen}, "
|
||||
f"Temp: {sensor.sample_reading.get('temperature', 'N/A')}°C, "
|
||||
f"Humidity: {sensor.sample_reading.get('humidity', 'N/A')}%, "
|
||||
f"Battery: {sensor.sample_reading.get('battery_percent', 'N/A')}%"
|
||||
)
|
||||
|
||||
|
||||
def display_sensor(sensor: DiscoveredSensor, index: int, total: int):
|
||||
"""
|
||||
Display sensor information to the user.
|
||||
|
||||
Args:
|
||||
sensor: Discovered sensor to display
|
||||
index: Current sensor number (1-based)
|
||||
total: Total number of sensors
|
||||
"""
|
||||
print(f"\n{'='*70}")
|
||||
print(f"Sensor {index}/{total}")
|
||||
print(f"{'='*70}")
|
||||
print(f"MAC Address: {sensor.mac}")
|
||||
print(f"Device Name: {sensor.name}")
|
||||
print(f"Last Seen: {sensor.last_seen}")
|
||||
print(f"Status: {sensor.status}")
|
||||
|
||||
if sensor.status == "ignored" and sensor.ignored_at:
|
||||
print(f"Ignored At: {sensor.ignored_at}")
|
||||
if sensor.ignore_reason:
|
||||
print(f"Reason: {sensor.ignore_reason}")
|
||||
|
||||
# Display sample reading
|
||||
reading = sensor.sample_reading
|
||||
print(f"\nSample Reading:")
|
||||
print(f" Temperature: {reading.get('temperature', 'N/A')}°C")
|
||||
print(f" Humidity: {reading.get('humidity', 'N/A')}%")
|
||||
print(f" Battery: {reading.get('battery_percent', 'N/A')}%")
|
||||
print(f" Voltage: {reading.get('battery_voltage', 'N/A')}mV")
|
||||
print(f"{'='*70}")
|
||||
|
||||
|
||||
def get_user_choice() -> str:
|
||||
"""
|
||||
Get user's choice for what to do with the sensor.
|
||||
|
||||
Returns:
|
||||
User choice: 'a' (approve), 'i' (ignore), 's' (skip)
|
||||
"""
|
||||
while True:
|
||||
choice = input("\n[A]pprove, [I]gnore, [S]kip, [Q]uit? ").strip().lower()
|
||||
if choice in ['a', 'i', 's', 'q']:
|
||||
return choice
|
||||
print("Invalid choice. Please enter A, I, S, or Q.")
|
||||
|
||||
|
||||
def approve_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager):
|
||||
"""
|
||||
Approve a sensor and add it to sensors.json.
|
||||
|
||||
Args:
|
||||
sensor: Sensor to approve
|
||||
manager: Discovery manager
|
||||
"""
|
||||
# Check if sensor already exists in sensors.json
|
||||
sensor_config_path = Path(config.SENSOR_CONFIG_FILE)
|
||||
|
||||
try:
|
||||
with open(sensor_config_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check for duplicates
|
||||
for existing_sensor in data.get('sensors', []):
|
||||
if existing_sensor.get('mac', '').upper() == sensor.mac:
|
||||
print(f"\n⚠️ Sensor {sensor.mac} already exists in sensors.json")
|
||||
print(" Renaming must be done manually in the file.")
|
||||
return
|
||||
|
||||
except FileNotFoundError:
|
||||
# File doesn't exist yet, create with empty sensors list
|
||||
data = {'sensors': []}
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"\n❌ Error: Invalid JSON in {sensor_config_path}: {e}")
|
||||
return
|
||||
|
||||
# Get sensor name from user
|
||||
while True:
|
||||
name = input("\nEnter sensor name (required): ").strip()
|
||||
if name:
|
||||
break
|
||||
print("Sensor name cannot be empty.")
|
||||
|
||||
# Pre-fill comment with metadata
|
||||
default_comment = format_metadata_comment(sensor)
|
||||
print(f"\nDefault comment:")
|
||||
print(f" {default_comment}")
|
||||
|
||||
edit = input("\nEdit comment? [y/N]: ").strip().lower()
|
||||
if edit == 'y':
|
||||
print("\nEnter comment (or press Enter to keep default):")
|
||||
comment = input("> ").strip()
|
||||
if not comment:
|
||||
comment = default_comment
|
||||
else:
|
||||
comment = default_comment
|
||||
|
||||
# Add to sensors.json
|
||||
new_sensor = {
|
||||
"mac": sensor.mac,
|
||||
"name": name
|
||||
}
|
||||
|
||||
if comment:
|
||||
new_sensor["comment"] = comment
|
||||
|
||||
data.setdefault('sensors', []).append(new_sensor)
|
||||
|
||||
try:
|
||||
with open(sensor_config_path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
print(f"\n✅ Sensor approved and added to sensors.json")
|
||||
print(f" Name: {name}")
|
||||
print(f" Configuration will be reloaded automatically within 15 minutes")
|
||||
|
||||
# Mark as approved in discovery manager and save
|
||||
print(f"\nUpdating discovery status...")
|
||||
manager.approve(sensor.mac)
|
||||
print(f"✅ Marked as approved in discovered_sensors.json")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error saving to sensors.json: {e}")
|
||||
|
||||
|
||||
def ignore_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager):
|
||||
"""
|
||||
Ignore a sensor.
|
||||
|
||||
Args:
|
||||
sensor: Sensor to ignore
|
||||
manager: Discovery manager
|
||||
"""
|
||||
reason = input("\nReason for ignoring (optional): ").strip()
|
||||
|
||||
manager.ignore(sensor.mac, reason if reason else None)
|
||||
|
||||
print(f"\n✅ Sensor ignored and marked in discovered_sensors.json")
|
||||
if reason:
|
||||
print(f" Reason: {reason}")
|
||||
|
||||
|
||||
def process_sensors(sensors: List[DiscoveredSensor], manager: DiscoveryManager):
|
||||
"""
|
||||
Process list of sensors interactively.
|
||||
|
||||
Args:
|
||||
sensors: List of sensors to process
|
||||
manager: Discovery manager
|
||||
"""
|
||||
if not sensors:
|
||||
print("\n✅ No sensors to process")
|
||||
return
|
||||
|
||||
print(f"\nFound {len(sensors)} sensor(s) to review")
|
||||
|
||||
for i, sensor in enumerate(sensors, 1):
|
||||
# Mark as reviewed when shown
|
||||
manager.mark_reviewed(sensor.mac)
|
||||
|
||||
display_sensor(sensor, i, len(sensors))
|
||||
|
||||
choice = get_user_choice()
|
||||
|
||||
if choice == 'q':
|
||||
print("\n👋 Exiting...")
|
||||
break
|
||||
elif choice == 'a':
|
||||
approve_sensor(sensor, manager)
|
||||
elif choice == 'i':
|
||||
ignore_sensor(sensor, manager)
|
||||
elif choice == 's':
|
||||
print("\n⏭️ Skipped")
|
||||
continue
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for approve-sensors CLI."""
|
||||
# Parse command line arguments
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Approve or ignore discovered Bluetooth sensors",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s # Show only new pending sensors
|
||||
%(prog)s --all # Show all pending sensors (including reviewed)
|
||||
%(prog)s --ignored # Show only ignored sensors
|
||||
%(prog)s --all --ignored # Show all sensors
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all', '-a',
|
||||
action='store_true',
|
||||
help='Show all pending sensors, including previously reviewed ones'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--ignored', '-i',
|
||||
action='store_true',
|
||||
help='Show ignored sensors'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING,
|
||||
format='%(levelname)s: %(message)s'
|
||||
)
|
||||
|
||||
print("=" * 70)
|
||||
print("Sensorpajen - Approve Sensors")
|
||||
print("=" * 70)
|
||||
|
||||
try:
|
||||
# Load discovery manager
|
||||
manager = DiscoveryManager()
|
||||
|
||||
# Get sensors based on flags
|
||||
if args.all:
|
||||
pending = manager.get_pending()
|
||||
pending_label = "all pending"
|
||||
else:
|
||||
pending = manager.get_new_pending()
|
||||
pending_label = "new pending"
|
||||
|
||||
ignored = manager.get_ignored() if args.ignored else []
|
||||
|
||||
if not pending and not ignored:
|
||||
if args.all or args.ignored:
|
||||
print(f"\n✅ No {pending_label if pending else 'ignored'} sensors found")
|
||||
else:
|
||||
print("\n✅ No new sensors to review")
|
||||
all_pending = manager.get_pending()
|
||||
if all_pending:
|
||||
print(f"\nThere are {len(all_pending)} previously reviewed pending sensor(s).")
|
||||
print("Run with --all to review them again.")
|
||||
return 0
|
||||
|
||||
# Process pending sensors
|
||||
if pending:
|
||||
print(f"\n📋 Processing {len(pending)} {pending_label} sensor(s)...")
|
||||
process_sensors(pending, manager)
|
||||
|
||||
# Process ignored sensors if requested
|
||||
if ignored:
|
||||
if pending:
|
||||
print("\n" + "=" * 70)
|
||||
print(f"\n📋 Processing {len(ignored)} ignored sensor(s)...")
|
||||
process_sensors(ignored, manager)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Done!")
|
||||
print("=" * 70)
|
||||
|
||||
return 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n👋 Interrupted by user")
|
||||
return 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}", exc_info=True)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
160
src/sensorpajen/config.py
Normal file
160
src/sensorpajen/config.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Configuration management for Sensorpajen.
|
||||
|
||||
Loads configuration from environment variables with sensible defaults.
|
||||
Configuration files are loaded relative to the project root.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Determine project root and config directory
|
||||
# Check if running from system installation (/opt/sensorpajen) or development
|
||||
_opt_sensorpajen_exists = Path('/opt/sensorpajen').exists()
|
||||
_var_lib_exists = Path('/var/lib/sensorpajen').exists()
|
||||
|
||||
if _opt_sensorpajen_exists:
|
||||
# System installation
|
||||
PROJECT_ROOT = Path('/opt/sensorpajen')
|
||||
CONFIG_DIR = Path('/etc/sensorpajen')
|
||||
STATE_DIR = Path('/var/lib/sensorpajen')
|
||||
else:
|
||||
# Development installation (3 levels up from this file: src/sensorpajen/config.py)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
STATE_DIR = CONFIG_DIR
|
||||
|
||||
# MQTT Configuration from environment
|
||||
MQTT_HOST = os.environ.get("MQTT_HOST")
|
||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
MQTT_USER = os.environ.get("MQTT_USER")
|
||||
MQTT_PASSWORD = os.environ.get("MQTT_PASSWORD")
|
||||
MQTT_CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "sensorpajen")
|
||||
MQTT_TOPIC_PREFIX = os.environ.get("MQTT_TOPIC_PREFIX", "MiTemperature2")
|
||||
|
||||
# Validate required MQTT configuration
|
||||
if not MQTT_HOST:
|
||||
raise RuntimeError(
|
||||
"MQTT_HOST environment variable must be set. "
|
||||
"Please configure config/sensorpajen.env"
|
||||
)
|
||||
|
||||
# Sensor configuration file
|
||||
SENSOR_CONFIG_FILE = os.environ.get(
|
||||
"SENSOR_CONFIG_FILE",
|
||||
str(CONFIG_DIR / "sensors.json")
|
||||
)
|
||||
|
||||
# Application settings
|
||||
WATCHDOG_TIMEOUT = int(os.environ.get("WATCHDOG_TIMEOUT", "5"))
|
||||
ENABLE_BATTERY = os.environ.get("ENABLE_BATTERY", "true").lower() == "true"
|
||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
# Bluetooth settings
|
||||
SKIP_IDENTICAL = int(os.environ.get("SKIP_IDENTICAL", "50"))
|
||||
DEBOUNCE = os.environ.get("DEBOUNCE", "true").lower() == "true"
|
||||
|
||||
# ntfy notification settings (optional)
|
||||
NTFY_ENABLED = os.environ.get("NTFY_ENABLED", "false").lower() == "true"
|
||||
NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.sh")
|
||||
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "sensorpajen")
|
||||
NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "")
|
||||
|
||||
# Discovery settings
|
||||
DISCOVERED_SENSORS_FILE = os.environ.get(
|
||||
"DISCOVERED_SENSORS_FILE",
|
||||
str(STATE_DIR / "discovered_sensors.json")
|
||||
)
|
||||
CONFIG_RELOAD_INTERVAL = int(os.environ.get("CONFIG_RELOAD_INTERVAL", "900")) # 15 minutes
|
||||
|
||||
|
||||
class SensorConfig:
|
||||
"""Manages sensor configuration from JSON file."""
|
||||
|
||||
def __init__(self, config_file: str = SENSOR_CONFIG_FILE):
|
||||
"""
|
||||
Initialize sensor configuration.
|
||||
|
||||
Args:
|
||||
config_file: Path to sensors JSON configuration file
|
||||
"""
|
||||
self.config_file = Path(config_file)
|
||||
self.sensors: Dict[str, str] = {}
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""Load sensor configuration from JSON file."""
|
||||
if not self.config_file.exists():
|
||||
logger.warning(
|
||||
f"Sensor configuration file not found: {self.config_file}\n"
|
||||
f"Starting with no sensors - use discovery to add sensors"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Convert sensors list to MAC -> name mapping
|
||||
for sensor in data.get('sensors', []):
|
||||
mac = sensor.get('mac', '').upper()
|
||||
name = sensor.get('name')
|
||||
|
||||
if mac and name:
|
||||
self.sensors[mac] = name
|
||||
logger.debug(f"Loaded sensor: {mac} -> {name}")
|
||||
|
||||
logger.info(f"Loaded {len(self.sensors)} sensors from {self.config_file}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Invalid JSON in {self.config_file}: {e}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error loading sensor config: {e}")
|
||||
|
||||
def get_name(self, mac: str) -> str:
|
||||
"""
|
||||
Get sensor name by MAC address.
|
||||
|
||||
Args:
|
||||
mac: MAC address (any case)
|
||||
|
||||
Returns:
|
||||
Sensor name or the MAC address if not found
|
||||
"""
|
||||
return self.sensors.get(mac.upper(), mac)
|
||||
|
||||
def get_all_macs(self) -> List[str]:
|
||||
"""Get list of all configured MAC addresses."""
|
||||
return list(self.sensors.keys())
|
||||
|
||||
|
||||
def validate_config():
|
||||
"""
|
||||
Validate configuration and log settings.
|
||||
Should be called at application startup.
|
||||
"""
|
||||
install_type = "System" if Path('/opt/sensorpajen').exists() else "Development"
|
||||
logger.info("=== Sensorpajen Configuration ===")
|
||||
logger.info(f"Installation Type: {install_type}")
|
||||
logger.info(f"Project Root: {PROJECT_ROOT}")
|
||||
logger.info(f"Config Directory: {CONFIG_DIR}")
|
||||
logger.info(f"State Directory: {STATE_DIR}")
|
||||
logger.info(f"MQTT Host: {MQTT_HOST}:{MQTT_PORT}")
|
||||
logger.info(f"MQTT User: {MQTT_USER}")
|
||||
logger.info(f"MQTT Client ID: {MQTT_CLIENT_ID}")
|
||||
logger.info(f"MQTT Topic Prefix: {MQTT_TOPIC_PREFIX}")
|
||||
logger.info(f"Sensor Config: {SENSOR_CONFIG_FILE}")
|
||||
logger.info(f"Discovered Sensors: {DISCOVERED_SENSORS_FILE}")
|
||||
logger.info(f"Watchdog Timeout: {WATCHDOG_TIMEOUT}s")
|
||||
logger.info(f"Battery Monitoring: {ENABLE_BATTERY}")
|
||||
logger.info(f"Config Reload Interval: {CONFIG_RELOAD_INTERVAL}s")
|
||||
logger.info(f"ntfy Enabled: {NTFY_ENABLED}")
|
||||
if NTFY_ENABLED:
|
||||
logger.info(f"ntfy URL: {NTFY_URL}/{NTFY_TOPIC}")
|
||||
logger.info(f"Log Level: {LOG_LEVEL}")
|
||||
logger.info("================================")
|
||||
263
src/sensorpajen/discovery_manager.py
Normal file
263
src/sensorpajen/discovery_manager.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Discovery manager for tracking and managing discovered sensors.
|
||||
|
||||
Maintains a database of discovered sensors with their metadata and status.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredSensor:
|
||||
"""Represents a discovered sensor with metadata."""
|
||||
mac: str
|
||||
name: str
|
||||
rssi: int
|
||||
first_seen: str
|
||||
last_seen: str
|
||||
sample_reading: Dict[str, float]
|
||||
status: str = "pending" # pending, approved, ignored
|
||||
reviewed: bool = False # Has been shown in approval CLI
|
||||
ignored_at: Optional[str] = None
|
||||
ignore_reason: Optional[str] = None
|
||||
|
||||
|
||||
class DiscoveryManager:
|
||||
"""Manages discovered sensors and their approval status."""
|
||||
|
||||
def __init__(self, discovery_file: str = config.DISCOVERED_SENSORS_FILE):
|
||||
"""
|
||||
Initialize discovery manager.
|
||||
|
||||
Args:
|
||||
discovery_file: Path to discovered sensors JSON file
|
||||
"""
|
||||
self.discovery_file = Path(discovery_file)
|
||||
self.sensors: Dict[str, DiscoveredSensor] = {}
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""Load discovered sensors from JSON file."""
|
||||
if not self.discovery_file.exists():
|
||||
logger.info(f"Creating new discovered sensors file: {self.discovery_file}")
|
||||
self.discovery_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.save()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.discovery_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
for sensor_data in data:
|
||||
sensor = DiscoveredSensor(**sensor_data)
|
||||
self.sensors[sensor.mac.upper()] = sensor
|
||||
|
||||
logger.info(f"Loaded {len(self.sensors)} discovered sensors")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON in {self.discovery_file}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading discovered sensors: {e}")
|
||||
|
||||
def save(self):
|
||||
"""Save discovered sensors to JSON file."""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
self.discovery_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Convert sensors to list of dicts
|
||||
data = [asdict(sensor) for sensor in self.sensors.values()]
|
||||
|
||||
with open(self.discovery_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
logger.debug(f"Saved {len(self.sensors)} discovered sensors")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving discovered sensors: {e}")
|
||||
|
||||
def add_or_update(self, mac: str, name: str, rssi: int,
|
||||
temperature: float, humidity: float,
|
||||
battery_percent: int, battery_voltage: int) -> bool:
|
||||
"""
|
||||
Add or update a discovered sensor.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
name: Advertised device name
|
||||
rssi: Signal strength
|
||||
temperature: Temperature reading
|
||||
humidity: Humidity reading
|
||||
battery_percent: Battery percentage
|
||||
battery_voltage: Battery voltage in mV
|
||||
|
||||
Returns:
|
||||
True if this is a newly discovered sensor, False if updated existing
|
||||
"""
|
||||
mac = mac.upper()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
sample_reading = {
|
||||
"temperature": temperature,
|
||||
"humidity": humidity,
|
||||
"battery_percent": battery_percent,
|
||||
"battery_voltage": battery_voltage
|
||||
}
|
||||
|
||||
if mac in self.sensors:
|
||||
# Update existing sensor
|
||||
sensor = self.sensors[mac]
|
||||
sensor.last_seen = now
|
||||
sensor.rssi = rssi
|
||||
sensor.sample_reading = sample_reading
|
||||
self.save()
|
||||
return False
|
||||
else:
|
||||
# New sensor discovered
|
||||
sensor = DiscoveredSensor(
|
||||
mac=mac,
|
||||
name=name,
|
||||
rssi=rssi,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
sample_reading=sample_reading,
|
||||
status="pending"
|
||||
)
|
||||
self.sensors[mac] = sensor
|
||||
self.save()
|
||||
logger.info(f"New sensor discovered: {mac} ({name})")
|
||||
return True
|
||||
|
||||
def is_known(self, mac: str) -> bool:
|
||||
"""
|
||||
Check if a sensor has been discovered before.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
|
||||
Returns:
|
||||
True if sensor is in discovered list
|
||||
"""
|
||||
return mac.upper() in self.sensors
|
||||
|
||||
def get_status(self, mac: str) -> Optional[str]:
|
||||
"""
|
||||
Get status of a discovered sensor.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
|
||||
Returns:
|
||||
Status string or None if not found
|
||||
"""
|
||||
sensor = self.sensors.get(mac.upper())
|
||||
return sensor.status if sensor else None
|
||||
|
||||
def approve(self, mac: str):
|
||||
"""
|
||||
Mark a sensor as approved.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
"""
|
||||
mac = mac.upper()
|
||||
if mac in self.sensors:
|
||||
self.sensors[mac].status = "approved"
|
||||
self.save()
|
||||
logger.info(f"Sensor approved: {mac}")
|
||||
|
||||
def ignore(self, mac: str, reason: Optional[str] = None):
|
||||
"""
|
||||
Mark a sensor as ignored.
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
reason: Optional reason for ignoring
|
||||
"""
|
||||
mac = mac.upper()
|
||||
if mac in self.sensors:
|
||||
self.sensors[mac].status = "ignored"
|
||||
self.sensors[mac].ignored_at = datetime.now().isoformat()
|
||||
self.sensors[mac].ignore_reason = reason
|
||||
self.save()
|
||||
logger.info(f"Sensor ignored: {mac}")
|
||||
|
||||
def get_pending(self) -> List[DiscoveredSensor]:
|
||||
"""Get list of sensors with status 'pending'."""
|
||||
return [s for s in self.sensors.values() if s.status == "pending"]
|
||||
|
||||
def get_new_pending(self) -> List[DiscoveredSensor]:
|
||||
"""Get list of pending sensors that haven't been reviewed yet."""
|
||||
return [s for s in self.sensors.values() if s.status == "pending" and not s.reviewed]
|
||||
|
||||
def get_ignored(self) -> List[DiscoveredSensor]:
|
||||
"""Get list of sensors with status 'ignored'."""
|
||||
return [s for s in self.sensors.values() if s.status == "ignored"]
|
||||
|
||||
def mark_reviewed(self, mac: str):
|
||||
"""
|
||||
Mark a sensor as reviewed (shown in approval CLI).
|
||||
|
||||
Args:
|
||||
mac: MAC address
|
||||
"""
|
||||
mac = mac.upper()
|
||||
if mac in self.sensors:
|
||||
self.sensors[mac].reviewed = True
|
||||
self.save()
|
||||
|
||||
def send_ntfy_notification(self, sensor: DiscoveredSensor):
|
||||
"""
|
||||
Send ntfy notification for a newly discovered sensor.
|
||||
|
||||
Args:
|
||||
sensor: Discovered sensor to notify about
|
||||
"""
|
||||
if not config.NTFY_ENABLED:
|
||||
logger.debug("ntfy notifications disabled")
|
||||
return
|
||||
|
||||
if not config.NTFY_TOKEN:
|
||||
logger.warning("ntfy enabled but NTFY_TOKEN not set")
|
||||
return
|
||||
|
||||
try:
|
||||
message = (
|
||||
f"🆕 New sensor discovered!\n\n"
|
||||
f"MAC: {sensor.mac}\n"
|
||||
f"Name: {sensor.name}\n"
|
||||
f"Last seen: {sensor.last_seen}\n"
|
||||
f"Temp: {sensor.sample_reading.get('temperature', 'N/A')}°C\n"
|
||||
f"Humidity: {sensor.sample_reading.get('humidity', 'N/A')}%\n"
|
||||
f"Battery: {sensor.sample_reading.get('battery_percent', 'N/A')}%\n\n"
|
||||
f"Run 'sensorpajen approve-sensors' to approve or ignore."
|
||||
)
|
||||
|
||||
url = f"{config.NTFY_URL}/{config.NTFY_TOPIC}"
|
||||
|
||||
result = subprocess.run(
|
||||
["curl", "-H", f"Authorization: Bearer {config.NTFY_TOKEN}",
|
||||
"-d", message, url],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Sent ntfy notification for {sensor.mac}")
|
||||
else:
|
||||
logger.warning(f"ntfy notification failed: {result.stderr.decode()}")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("ntfy notification timed out")
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending ntfy notification: {e}")
|
||||
226
src/sensorpajen/main.py
Normal file
226
src/sensorpajen/main.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sensorpajen - Main entry point
|
||||
|
||||
Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC sensors.
|
||||
Publishes sensor data to MQTT broker.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import signal
|
||||
import logging
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
from . import config
|
||||
from .mqtt_publisher import MQTTPublisher
|
||||
from .sensor_reader import SensorReader, Measurement
|
||||
from .discovery_manager import DiscoveryManager
|
||||
|
||||
|
||||
class Sensorpajen:
|
||||
"""Main application class."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the application."""
|
||||
self.mqtt_publisher: MQTTPublisher = None
|
||||
self.sensor_reader: SensorReader = None
|
||||
self.sensor_config: config.SensorConfig = None
|
||||
self.discovery_manager: DiscoveryManager = None
|
||||
self.running = False
|
||||
self.config_reload_timer: threading.Timer = None
|
||||
|
||||
# Setup logging
|
||||
self._setup_logging()
|
||||
|
||||
# Setup signal handlers
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
|
||||
def _setup_logging(self):
|
||||
"""Configure logging to stdout for journald."""
|
||||
log_level = getattr(logging, config.LOG_LEVEL, logging.INFO)
|
||||
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
stream=sys.stdout
|
||||
)
|
||||
|
||||
# Set our logger
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def _signal_handler(self, sig, frame):
|
||||
"""Handle shutdown signals."""
|
||||
signal_name = "SIGTERM" if sig == signal.SIGTERM else "SIGINT"
|
||||
self.logger.info(f"Received {signal_name}, shutting down gracefully...")
|
||||
self.shutdown()
|
||||
sys.exit(0)
|
||||
|
||||
def _on_measurement(self, measurement: Measurement):
|
||||
"""
|
||||
Callback for new sensor measurements.
|
||||
|
||||
Args:
|
||||
measurement: Sensor measurement data
|
||||
"""
|
||||
try:
|
||||
# Publish to MQTT
|
||||
self.mqtt_publisher.publish_measurement(
|
||||
sensor_name=measurement.sensor_name,
|
||||
temperature=measurement.temperature,
|
||||
humidity=measurement.humidity,
|
||||
battery_voltage=measurement.voltage,
|
||||
battery_level=measurement.battery
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling measurement: {e}")
|
||||
|
||||
def _reload_config(self):
|
||||
"""Reload sensor configuration periodically."""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
try:
|
||||
self.logger.info("Reloading sensor configuration...")
|
||||
old_sensors = set(self.sensor_config.sensors.keys())
|
||||
self.sensor_config.load()
|
||||
new_sensors = set(self.sensor_config.sensors.keys())
|
||||
|
||||
added = new_sensors - old_sensors
|
||||
removed = old_sensors - new_sensors
|
||||
|
||||
if added:
|
||||
self.logger.info(f"Added sensors: {', '.join(added)}")
|
||||
if removed:
|
||||
self.logger.info(f"Removed sensors: {', '.join(removed)}")
|
||||
if not added and not removed:
|
||||
self.logger.debug("No sensor configuration changes")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reloading configuration: {e}")
|
||||
finally:
|
||||
# Schedule next reload
|
||||
if self.running:
|
||||
self.config_reload_timer = threading.Timer(
|
||||
config.CONFIG_RELOAD_INTERVAL,
|
||||
self._reload_config
|
||||
)
|
||||
self.config_reload_timer.daemon = True
|
||||
self.config_reload_timer.start()
|
||||
|
||||
def start(self):
|
||||
"""Start the application."""
|
||||
try:
|
||||
self.logger.info("=" * 50)
|
||||
self.logger.info(f"Starting Sensorpajen v{__version__}")
|
||||
self.logger.info("=" * 50)
|
||||
|
||||
# Validate and log configuration
|
||||
config.validate_config()
|
||||
|
||||
# Load sensor configuration
|
||||
self.sensor_config = config.SensorConfig()
|
||||
|
||||
if len(self.sensor_config.sensors) == 0:
|
||||
self.logger.warning("No sensors configured")
|
||||
self.logger.warning("Starting in discovery-only mode")
|
||||
self.logger.warning("Use 'sensorpajen approve-sensors' to add sensors")
|
||||
|
||||
# Initialize discovery manager
|
||||
self.logger.info("Initializing discovery manager...")
|
||||
self.discovery_manager = DiscoveryManager()
|
||||
|
||||
# Initialize MQTT publisher
|
||||
self.logger.info("Initializing MQTT publisher...")
|
||||
self.mqtt_publisher = MQTTPublisher()
|
||||
self.mqtt_publisher.connect()
|
||||
|
||||
# Wait a moment for MQTT connection
|
||||
time.sleep(1)
|
||||
|
||||
if not self.mqtt_publisher.is_connected():
|
||||
self.logger.warning("MQTT connection not established yet, continuing anyway...")
|
||||
|
||||
# Initialize sensor reader
|
||||
self.logger.info("Initializing Bluetooth sensor reader...")
|
||||
self.sensor_reader = SensorReader(
|
||||
sensor_config=self.sensor_config,
|
||||
discovery_manager=self.discovery_manager,
|
||||
on_measurement=self._on_measurement,
|
||||
interface=0 # hci0
|
||||
)
|
||||
|
||||
# Start config reload timer
|
||||
self.config_reload_timer = threading.Timer(
|
||||
config.CONFIG_RELOAD_INTERVAL,
|
||||
self._reload_config
|
||||
)
|
||||
self.config_reload_timer.daemon = True
|
||||
self.config_reload_timer.start()
|
||||
self.logger.info(f"Config reload scheduled every {config.CONFIG_RELOAD_INTERVAL}s")
|
||||
|
||||
# Start reading sensors (blocking call)
|
||||
self.logger.info("=" * 50)
|
||||
self.logger.info("Sensorpajen is now running")
|
||||
self.logger.info("Monitoring sensors via Bluetooth...")
|
||||
self.logger.info("Publishing to MQTT...")
|
||||
self.logger.info("Press Ctrl+C to stop")
|
||||
self.logger.info("=" * 50)
|
||||
|
||||
self.running = True
|
||||
self.sensor_reader.start()
|
||||
|
||||
except FileNotFoundError as e:
|
||||
self.logger.error(f"Configuration error: {e}")
|
||||
sys.exit(1)
|
||||
except RuntimeError as e:
|
||||
self.logger.error(f"Configuration error: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start application: {e}", exc_info=True)
|
||||
self.shutdown()
|
||||
sys.exit(1)
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the application gracefully."""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.running = False
|
||||
self.logger.info("Shutting down...")
|
||||
|
||||
# Cancel config reload timer
|
||||
if self.config_reload_timer:
|
||||
try:
|
||||
self.config_reload_timer.cancel()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error canceling reload timer: {e}")
|
||||
|
||||
# Stop sensor reader
|
||||
if self.sensor_reader:
|
||||
try:
|
||||
self.sensor_reader.stop()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error stopping sensor reader: {e}")
|
||||
|
||||
# Disconnect MQTT
|
||||
if self.mqtt_publisher:
|
||||
try:
|
||||
self.mqtt_publisher.disconnect()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error disconnecting MQTT: {e}")
|
||||
|
||||
self.logger.info("Shutdown complete")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
app = Sensorpajen()
|
||||
app.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
131
src/sensorpajen/mqtt_publisher.py
Normal file
131
src/sensorpajen/mqtt_publisher.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
MQTT Publisher for sensor data.
|
||||
|
||||
Handles connection to MQTT broker and publishing of sensor measurements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import paho.mqtt.client as mqtt
|
||||
from typing import Optional
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MQTTPublisher:
|
||||
"""Manages MQTT connection and publishing of sensor data."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize MQTT publisher with configuration."""
|
||||
self.client: Optional[mqtt.Client] = None
|
||||
self.connected = False
|
||||
self._setup_client()
|
||||
|
||||
def _setup_client(self):
|
||||
"""Setup MQTT client with callbacks."""
|
||||
# Handle both paho-mqtt v1.x and v2.x
|
||||
try:
|
||||
# Try v2.x format (with callback_api_version)
|
||||
self.client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION1,
|
||||
client_id=config.MQTT_CLIENT_ID
|
||||
)
|
||||
except (TypeError, AttributeError):
|
||||
# Fall back to v1.x format
|
||||
self.client = mqtt.Client(config.MQTT_CLIENT_ID)
|
||||
|
||||
# Set credentials if provided
|
||||
if config.MQTT_USER and config.MQTT_PASSWORD:
|
||||
self.client.username_pw_set(config.MQTT_USER, config.MQTT_PASSWORD)
|
||||
|
||||
# Setup callbacks
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
self.client.on_publish = self._on_publish
|
||||
|
||||
logger.info(f"MQTT client configured for {config.MQTT_HOST}:{config.MQTT_PORT}")
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
"""Callback for when client connects to broker."""
|
||||
if rc == 0:
|
||||
self.connected = True
|
||||
logger.info(f"Connected to MQTT broker at {config.MQTT_HOST}:{config.MQTT_PORT}")
|
||||
else:
|
||||
self.connected = False
|
||||
logger.error(f"Failed to connect to MQTT broker. Return code: {rc}")
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc):
|
||||
"""Callback for when client disconnects from broker."""
|
||||
self.connected = False
|
||||
if rc != 0:
|
||||
logger.warning(f"Unexpected disconnection from MQTT broker. Return code: {rc}")
|
||||
else:
|
||||
logger.info("Disconnected from MQTT broker")
|
||||
|
||||
def _on_publish(self, client, userdata, mid):
|
||||
"""Callback for when message is published."""
|
||||
logger.debug(f"Message published: {mid}")
|
||||
|
||||
def connect(self):
|
||||
"""Connect to MQTT broker."""
|
||||
try:
|
||||
logger.info(f"Connecting to MQTT broker at {config.MQTT_HOST}:{config.MQTT_PORT}")
|
||||
self.client.connect(config.MQTT_HOST, config.MQTT_PORT, keepalive=60)
|
||||
self.client.loop_start() # Start network loop in background thread
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||
raise
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from MQTT broker."""
|
||||
if self.client:
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
logger.info("Disconnected from MQTT broker")
|
||||
|
||||
def publish_measurement(self, sensor_name: str, temperature: float,
|
||||
humidity: int, battery_voltage: float = None,
|
||||
battery_level: int = None):
|
||||
"""
|
||||
Publish sensor measurement to MQTT.
|
||||
|
||||
Args:
|
||||
sensor_name: Name of the sensor
|
||||
temperature: Temperature in Celsius
|
||||
humidity: Humidity percentage
|
||||
battery_voltage: Battery voltage (optional)
|
||||
battery_level: Battery level percentage (optional)
|
||||
"""
|
||||
if not self.connected:
|
||||
logger.warning("Not connected to MQTT broker, skipping publish")
|
||||
return
|
||||
|
||||
topic_prefix = f"{config.MQTT_TOPIC_PREFIX}/{sensor_name}"
|
||||
|
||||
try:
|
||||
# Publish temperature
|
||||
self.client.publish(f"{topic_prefix}/temp", f"{temperature:.1f}")
|
||||
logger.debug(f"{sensor_name}: temp={temperature:.1f}°C")
|
||||
|
||||
# Publish humidity
|
||||
self.client.publish(f"{topic_prefix}/humidity", f"{humidity}")
|
||||
logger.debug(f"{sensor_name}: humidity={humidity}%")
|
||||
|
||||
# Publish battery info if enabled and available
|
||||
if config.ENABLE_BATTERY:
|
||||
if battery_voltage is not None:
|
||||
self.client.publish(f"{topic_prefix}/batteryvoltage", f"{battery_voltage:.3f}")
|
||||
logger.debug(f"{sensor_name}: battery_voltage={battery_voltage:.3f}V")
|
||||
|
||||
if battery_level is not None:
|
||||
self.client.publish(f"{topic_prefix}/batterylevel", f"{battery_level}")
|
||||
logger.debug(f"{sensor_name}: battery_level={battery_level}%")
|
||||
|
||||
logger.info(f"Published: {sensor_name} - {temperature:.1f}°C, {humidity}%")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error publishing to MQTT: {e}")
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected to MQTT broker."""
|
||||
return self.connected
|
||||
292
src/sensorpajen/sensor_reader.py
Normal file
292
src/sensorpajen/sensor_reader.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Bluetooth sensor reader for Xiaomi Mijia LYWSD03MMC sensors with ATC firmware.
|
||||
|
||||
Reads temperature, humidity, and battery data from BLE advertisements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import threading
|
||||
import bluetooth._bluetooth as bluez
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Callable, Dict
|
||||
|
||||
from . import config
|
||||
from .utils import (enable_le_scan, disable_le_scan,
|
||||
parse_le_advertising_events, raw_packet_to_str, toggle_device)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Measurement:
|
||||
"""Sensor measurement data."""
|
||||
temperature: float
|
||||
humidity: int
|
||||
voltage: float
|
||||
battery: int = 0
|
||||
rssi: int = 0
|
||||
sensor_name: str = ""
|
||||
timestamp: int = 0
|
||||
|
||||
|
||||
class SensorReader:
|
||||
"""Reads Xiaomi LYWSD03MMC sensors with ATC firmware via BLE."""
|
||||
|
||||
def __init__(self, sensor_config: config.SensorConfig,
|
||||
discovery_manager,
|
||||
on_measurement: Callable[[Measurement], None],
|
||||
interface: int = 0):
|
||||
"""
|
||||
Initialize sensor reader.
|
||||
|
||||
Args:
|
||||
sensor_config: Sensor configuration mapping
|
||||
discovery_manager: Discovery manager for tracking new sensors
|
||||
on_measurement: Callback function for new measurements
|
||||
interface: Bluetooth interface number (default 0 for hci0)
|
||||
"""
|
||||
self.sensor_config = sensor_config
|
||||
self.discovery_manager = discovery_manager
|
||||
self.on_measurement = on_measurement
|
||||
self.interface = interface
|
||||
self.sock: Optional[int] = None
|
||||
self.running = False
|
||||
self.last_ble_packet = time.time()
|
||||
self.adv_counter: Dict[str, str] = {} # Track advertisement numbers to avoid duplicates
|
||||
self.watchdog_thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self):
|
||||
"""Start BLE scanning for sensors."""
|
||||
try:
|
||||
logger.info(f"Starting BLE scan on hci{self.interface}")
|
||||
|
||||
# Enable bluetooth device
|
||||
toggle_device(self.interface, True)
|
||||
|
||||
# Open bluetooth socket
|
||||
try:
|
||||
self.sock = bluez.hci_open_dev(self.interface)
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot open bluetooth device hci{self.interface}: {e}")
|
||||
raise
|
||||
|
||||
# Enable LE scanning without filtering duplicates
|
||||
enable_le_scan(self.sock, filter_duplicates=False)
|
||||
|
||||
# Start watchdog if configured
|
||||
if config.WATCHDOG_TIMEOUT > 0:
|
||||
self.running = True
|
||||
self.watchdog_thread = threading.Thread(target=self._watchdog_loop, daemon=True)
|
||||
self.watchdog_thread.start()
|
||||
logger.info(f"Watchdog started with {config.WATCHDOG_TIMEOUT}s timeout")
|
||||
|
||||
logger.info("BLE scanning enabled")
|
||||
logger.info(f"Monitoring {len(self.sensor_config.sensors)} sensors")
|
||||
|
||||
# Start parsing advertisements (blocking call)
|
||||
parse_le_advertising_events(
|
||||
self.sock,
|
||||
handler=self._handle_ble_packet,
|
||||
debug=False
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received keyboard interrupt")
|
||||
self.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sensor reader: {e}")
|
||||
self.stop()
|
||||
raise
|
||||
|
||||
def stop(self):
|
||||
"""Stop BLE scanning."""
|
||||
self.running = False
|
||||
|
||||
if self.sock:
|
||||
try:
|
||||
disable_le_scan(self.sock)
|
||||
logger.info("BLE scanning disabled")
|
||||
except Exception as e:
|
||||
logger.error(f"Error disabling BLE scan: {e}")
|
||||
|
||||
if self.watchdog_thread and self.watchdog_thread.is_alive():
|
||||
self.watchdog_thread.join(timeout=2)
|
||||
|
||||
def _watchdog_loop(self):
|
||||
"""Watchdog thread to restart BLE scanning if no packets received."""
|
||||
restart_counter = 1
|
||||
|
||||
while self.running:
|
||||
time.sleep(1)
|
||||
now = time.time()
|
||||
elapsed = now - self.last_ble_packet
|
||||
|
||||
if elapsed > config.WATCHDOG_TIMEOUT:
|
||||
logger.warning(
|
||||
f"Watchdog: No BLE packet within {int(elapsed)}s. "
|
||||
f"Restarting BLE scan (count: {restart_counter})"
|
||||
)
|
||||
try:
|
||||
disable_le_scan(self.sock)
|
||||
time.sleep(1)
|
||||
enable_le_scan(self.sock, filter_duplicates=False)
|
||||
restart_counter += 1
|
||||
self.last_ble_packet = now # Reset timer
|
||||
except Exception as e:
|
||||
logger.error(f"Error restarting BLE scan: {e}")
|
||||
|
||||
def _handle_ble_packet(self, mac: str, adv_type: int, data: bytes, rssi: int):
|
||||
"""
|
||||
Handle incoming BLE advertisement packet.
|
||||
|
||||
Args:
|
||||
mac: MAC address of the device
|
||||
adv_type: Advertisement type
|
||||
data: Advertisement data
|
||||
rssi: Signal strength
|
||||
"""
|
||||
# Update last packet time for watchdog
|
||||
self.last_ble_packet = time.time()
|
||||
|
||||
# Convert data to hex string
|
||||
data_str = raw_packet_to_str(data)
|
||||
|
||||
# Check if this is an ATC packet
|
||||
# ATC format: [... service UUID 0x181A ... MAC ... data ...]
|
||||
atc_identifier = data_str[6:10].upper()
|
||||
if atc_identifier != "1A18":
|
||||
return # Not an ATC packet
|
||||
|
||||
# Extract MAC from packet and verify it matches
|
||||
packet_mac = data_str[10:22].upper()
|
||||
mac_str = mac.replace(":", "").upper()
|
||||
|
||||
if packet_mac != mac_str:
|
||||
return # MAC mismatch
|
||||
|
||||
mac_with_colons = mac.upper()
|
||||
|
||||
# Parse ATC data packet first to get sensor data
|
||||
try:
|
||||
parsed_data = self._parse_atc_data(data_str)
|
||||
if not parsed_data:
|
||||
return
|
||||
|
||||
temperature, humidity, battery_percent, battery_voltage, adv_number = parsed_data
|
||||
|
||||
# Check if this is a known sensor
|
||||
if mac_with_colons not in self.sensor_config.sensors:
|
||||
# Unknown sensor - check if we should discover it
|
||||
self._handle_unknown_sensor(
|
||||
mac_with_colons,
|
||||
rssi,
|
||||
temperature,
|
||||
humidity,
|
||||
battery_percent,
|
||||
battery_voltage
|
||||
)
|
||||
return
|
||||
|
||||
# Check advertisement number to avoid duplicates
|
||||
if mac_str in self.adv_counter:
|
||||
if self.adv_counter[mac_str] == adv_number:
|
||||
return # Duplicate packet
|
||||
self.adv_counter[mac_str] = adv_number
|
||||
|
||||
# Create measurement for known sensor
|
||||
sensor_name = self.sensor_config.get_name(mac_with_colons)
|
||||
measurement = Measurement(
|
||||
temperature=temperature,
|
||||
humidity=humidity,
|
||||
voltage=battery_voltage / 1000.0,
|
||||
battery=battery_percent,
|
||||
rssi=rssi,
|
||||
sensor_name=sensor_name,
|
||||
timestamp=int(time.time())
|
||||
)
|
||||
|
||||
# Log the measurement
|
||||
logger.info(
|
||||
f"{measurement.sensor_name}: {measurement.temperature}°C, "
|
||||
f"{measurement.humidity}%, {measurement.voltage}V, "
|
||||
f"battery {measurement.battery}%, RSSI {rssi}dBm"
|
||||
)
|
||||
|
||||
# Call measurement callback
|
||||
if self.on_measurement:
|
||||
self.on_measurement(measurement)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing ATC packet from {mac}: {e}")
|
||||
|
||||
def _handle_unknown_sensor(self, mac: str, rssi: int, temperature: float,
|
||||
humidity: int, battery_percent: int, battery_voltage: int):
|
||||
"""
|
||||
Handle discovery of unknown sensor.
|
||||
|
||||
Args:
|
||||
mac: MAC address with colons
|
||||
rssi: Signal strength
|
||||
temperature: Temperature reading
|
||||
humidity: Humidity reading
|
||||
battery_percent: Battery percentage
|
||||
battery_voltage: Battery voltage in mV
|
||||
"""
|
||||
# Get or construct device name from MAC
|
||||
# ATC sensors advertise as ATC_XXXXXX where XXXXXX is last 3 bytes
|
||||
mac_suffix = mac.replace(":", "")[-6:]
|
||||
device_name = f"ATC_{mac_suffix}"
|
||||
|
||||
# Check if already discovered
|
||||
if self.discovery_manager.is_known(mac):
|
||||
# Just update the discovery record
|
||||
self.discovery_manager.add_or_update(
|
||||
mac, device_name, rssi, temperature, humidity,
|
||||
battery_percent, battery_voltage
|
||||
)
|
||||
return
|
||||
|
||||
# New sensor - discover and notify
|
||||
is_new = self.discovery_manager.add_or_update(
|
||||
mac, device_name, rssi, temperature, humidity,
|
||||
battery_percent, battery_voltage
|
||||
)
|
||||
|
||||
if is_new:
|
||||
logger.info(f"New sensor discovered: {mac} ({device_name})")
|
||||
sensor = self.discovery_manager.sensors[mac]
|
||||
self.discovery_manager.send_ntfy_notification(sensor)
|
||||
|
||||
def _parse_atc_data(self, data_str: str) -> Optional[tuple]:
|
||||
"""
|
||||
Parse ATC advertisement data.
|
||||
|
||||
Returns:
|
||||
Tuple of (temperature, humidity, battery_percent, battery_voltage, adv_number) or None
|
||||
"""
|
||||
try:
|
||||
# Temperature: bytes 22-26, signed int16, big endian, /10
|
||||
temp_hex = data_str[22:26]
|
||||
temp_raw = int(temp_hex, 16)
|
||||
if temp_raw & 0x8000: # Check sign bit
|
||||
temp_raw = temp_raw - 0x10000
|
||||
temperature = temp_raw / 10.0
|
||||
|
||||
# Humidity: bytes 26-28, uint8
|
||||
humidity = int(data_str[26:28], 16)
|
||||
|
||||
# Battery: bytes 28-30, uint8
|
||||
battery_percent = int(data_str[28:30], 16)
|
||||
|
||||
# Battery voltage: bytes 30-34, uint16, big endian, mV
|
||||
battery_voltage = int(data_str[30:34], 16)
|
||||
|
||||
# Advertisement number: last 2 bytes
|
||||
adv_number = data_str[-2:]
|
||||
|
||||
return (temperature, humidity, battery_percent, battery_voltage, adv_number)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"Error parsing ATC data: {e}")
|
||||
return None
|
||||
421
src/sensorpajen/utils.py
Normal file
421
src/sensorpajen/utils.py
Normal file
@@ -0,0 +1,421 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# This file is from https://github.com/colin-guyon/py-bluetooth-utils
|
||||
# published under MIT License
|
||||
|
||||
# MIT License
|
||||
|
||||
# Copyright (c) 2020 Colin GUYON
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
"""
|
||||
Module containing some bluetooth utility functions (linux only).
|
||||
|
||||
It either uses HCI commands using PyBluez, or does ioctl calls like it's
|
||||
done in Bluez tools such as hciconfig.
|
||||
|
||||
Main functions:
|
||||
- toggle_device : enable or disable a bluetooth device
|
||||
- set_scan : set scan type on a device ("noscan", "iscan", "pscan", "piscan")
|
||||
- enable/disable_le_scan : enable BLE scanning
|
||||
- parse_le_advertising_events : parse and read BLE advertisements packets
|
||||
- start/stop_le_advertising : advertise custom data using BLE
|
||||
|
||||
Bluez : http://www.bluez.org/
|
||||
PyBluez : http://karulis.github.io/pybluez/
|
||||
|
||||
The module was in particular inspired from 'iBeacon-Scanner-'
|
||||
https://github.com/switchdoclabs/iBeacon-Scanner-/blob/master/blescan.py
|
||||
and sometimes directly from the Bluez sources.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
import sys
|
||||
import struct
|
||||
import fcntl
|
||||
import array
|
||||
import socket
|
||||
from errno import EALREADY
|
||||
|
||||
# import PyBluez
|
||||
import bluetooth._bluetooth as bluez
|
||||
|
||||
__all__ = ('toggle_device', 'set_scan',
|
||||
'enable_le_scan', 'disable_le_scan', 'parse_le_advertising_events',
|
||||
'start_le_advertising', 'stop_le_advertising',
|
||||
'raw_packet_to_str')
|
||||
|
||||
LE_META_EVENT = 0x3E
|
||||
LE_PUBLIC_ADDRESS = 0x00
|
||||
LE_RANDOM_ADDRESS = 0x01
|
||||
|
||||
OGF_LE_CTL = 0x08
|
||||
OCF_LE_SET_SCAN_PARAMETERS = 0x000B
|
||||
OCF_LE_SET_SCAN_ENABLE = 0x000C
|
||||
OCF_LE_CREATE_CONN = 0x000D
|
||||
OCF_LE_SET_ADVERTISING_PARAMETERS = 0x0006
|
||||
OCF_LE_SET_ADVERTISE_ENABLE = 0x000A
|
||||
OCF_LE_SET_ADVERTISING_DATA = 0x0008
|
||||
|
||||
SCAN_TYPE_PASSIVE = 0x00
|
||||
SCAN_FILTER_DUPLICATES = 0x01
|
||||
SCAN_DISABLE = 0x00
|
||||
SCAN_ENABLE = 0x01
|
||||
|
||||
# sub-events of LE_META_EVENT
|
||||
EVT_LE_CONN_COMPLETE = 0x01
|
||||
EVT_LE_ADVERTISING_REPORT = 0x02
|
||||
EVT_LE_CONN_UPDATE_COMPLETE = 0x03
|
||||
EVT_LE_READ_REMOTE_USED_FEATURES_COMPLETE = 0x04
|
||||
|
||||
# Advertisement event types
|
||||
ADV_IND = 0x00
|
||||
ADV_DIRECT_IND = 0x01
|
||||
ADV_SCAN_IND = 0x02
|
||||
ADV_NONCONN_IND = 0x03
|
||||
ADV_SCAN_RSP = 0x04
|
||||
|
||||
# Allow Scan Request from Any, Connect Request from Any
|
||||
FILTER_POLICY_NO_WHITELIST = 0x00
|
||||
# Allow Scan Request from White List Only, Connect Request from Any
|
||||
FILTER_POLICY_SCAN_WHITELIST = 0x01
|
||||
# Allow Scan Request from Any, Connect Request from White List Only
|
||||
FILTER_POLICY_CONN_WHITELIST = 0x02
|
||||
# Allow Scan Request from White List Only, Connect Request from White List Only
|
||||
FILTER_POLICY_SCAN_AND_CONN_WHITELIST = 0x03
|
||||
|
||||
|
||||
def toggle_device(dev_id, enable):
|
||||
"""
|
||||
Power ON or OFF a bluetooth device.
|
||||
|
||||
:param dev_id: Device id.
|
||||
:type dev_id: ``int``
|
||||
:param enable: Whether to enable of disable the device.
|
||||
:type enable: ``bool``
|
||||
"""
|
||||
hci_sock = socket.socket(socket.AF_BLUETOOTH,
|
||||
socket.SOCK_RAW,
|
||||
socket.BTPROTO_HCI)
|
||||
print("Power %s bluetooth device %d" % ('ON' if enable else 'OFF', dev_id))
|
||||
# di = struct.pack("HbBIBBIIIHHHH10I", dev_id, *((0,) * 22))
|
||||
# fcntl.ioctl(hci_sock.fileno(), bluez.HCIGETDEVINFO, di)
|
||||
req_str = struct.pack("H", dev_id)
|
||||
request = array.array("b", req_str)
|
||||
try:
|
||||
fcntl.ioctl(hci_sock.fileno(),
|
||||
bluez.HCIDEVUP if enable else bluez.HCIDEVDOWN,
|
||||
request[0])
|
||||
except IOError as e:
|
||||
if e.errno == EALREADY:
|
||||
print("Bluetooth device %d is already %s" % (
|
||||
dev_id, 'enabled' if enable else 'disabled'))
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
hci_sock.close()
|
||||
|
||||
|
||||
# Types of bluetooth scan
|
||||
SCAN_DISABLED = 0x00
|
||||
SCAN_INQUIRY = 0x01
|
||||
SCAN_PAGE = 0x02
|
||||
|
||||
|
||||
def set_scan(dev_id, scan_type):
|
||||
"""
|
||||
Set scan type on a given bluetooth device.
|
||||
|
||||
:param dev_id: Device id.
|
||||
:type dev_id: ``int``
|
||||
:param scan_type: One of
|
||||
``'noscan'``
|
||||
``'iscan'``
|
||||
``'pscan'``
|
||||
``'piscan'``
|
||||
:type scan_type: ``str``
|
||||
"""
|
||||
hci_sock = socket.socket(socket.AF_BLUETOOTH,
|
||||
socket.SOCK_RAW,
|
||||
socket.BTPROTO_HCI)
|
||||
if scan_type == "noscan":
|
||||
dev_opt = SCAN_DISABLED
|
||||
elif scan_type == "iscan":
|
||||
dev_opt = SCAN_INQUIRY
|
||||
elif scan_type == "pscan":
|
||||
dev_opt = SCAN_PAGE
|
||||
elif scan_type == "piscan":
|
||||
dev_opt = SCAN_PAGE | SCAN_INQUIRY
|
||||
else:
|
||||
raise ValueError("Unknown scan type %r" % scan_type)
|
||||
|
||||
req_str = struct.pack("HI", dev_id, dev_opt)
|
||||
print("Set scan type %r to bluetooth device %d" % (scan_type, dev_id))
|
||||
try:
|
||||
fcntl.ioctl(hci_sock.fileno(), bluez.HCISETSCAN, req_str)
|
||||
finally:
|
||||
hci_sock.close()
|
||||
|
||||
|
||||
def raw_packet_to_str(pkt):
|
||||
"""
|
||||
Returns the string representation of a raw HCI packet.
|
||||
"""
|
||||
if sys.version_info > (3, 0):
|
||||
return ''.join('%02x' % struct.unpack("B", bytes([x]))[0] for x in pkt)
|
||||
else:
|
||||
return ''.join('%02x' % struct.unpack("B", x)[0] for x in pkt)
|
||||
|
||||
|
||||
def enable_le_scan(sock, interval=0x0800, window=0x0800,
|
||||
filter_policy=FILTER_POLICY_NO_WHITELIST,
|
||||
filter_duplicates=True):
|
||||
"""
|
||||
Enable LE passive scan (with filtering of duplicate packets enabled).
|
||||
|
||||
:param sock: A bluetooth HCI socket (retrieved using the
|
||||
``hci_open_dev`` PyBluez function).
|
||||
:param interval: Scan interval.
|
||||
:param window: Scan window (must be less or equal than given interval).
|
||||
:param filter_policy: One of
|
||||
``FILTER_POLICY_NO_WHITELIST`` (default value)
|
||||
``FILTER_POLICY_SCAN_WHITELIST``
|
||||
``FILTER_POLICY_CONN_WHITELIST``
|
||||
``FILTER_POLICY_SCAN_AND_CONN_WHITELIST``
|
||||
|
||||
.. note:: Scan interval and window are to multiply by 0.625 ms to
|
||||
get the real time duration.
|
||||
"""
|
||||
print("Enable LE scan")
|
||||
own_bdaddr_type = LE_PUBLIC_ADDRESS # does not work with LE_RANDOM_ADDRESS
|
||||
cmd_pkt = struct.pack("<BHHBB", SCAN_TYPE_PASSIVE, interval, window,
|
||||
own_bdaddr_type, filter_policy)
|
||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_PARAMETERS, cmd_pkt)
|
||||
print("scan params: interval=%.3fms window=%.3fms own_bdaddr=%s "
|
||||
"whitelist=%s" %
|
||||
(interval * 0.625, window * 0.625,
|
||||
'public' if own_bdaddr_type == LE_PUBLIC_ADDRESS else 'random',
|
||||
'yes' if filter_policy in (FILTER_POLICY_SCAN_WHITELIST,
|
||||
FILTER_POLICY_SCAN_AND_CONN_WHITELIST)
|
||||
else 'no'))
|
||||
cmd_pkt = struct.pack("<BB", SCAN_ENABLE, SCAN_FILTER_DUPLICATES if filter_duplicates else 0x00)
|
||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE, cmd_pkt)
|
||||
|
||||
|
||||
def disable_le_scan(sock):
|
||||
"""
|
||||
Disable LE scan.
|
||||
|
||||
:param sock: A bluetooth HCI socket (retrieved using the
|
||||
``hci_open_dev`` PyBluez function).
|
||||
"""
|
||||
print("Disable LE scan")
|
||||
cmd_pkt = struct.pack("<BB", SCAN_DISABLE, 0x00)
|
||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE, cmd_pkt)
|
||||
|
||||
|
||||
def start_le_advertising(sock, min_interval=1000, max_interval=1000,
|
||||
adv_type=ADV_NONCONN_IND, data=()):
|
||||
"""
|
||||
Start LE advertising.
|
||||
|
||||
:param sock: A bluetooth HCI socket (retrieved using the
|
||||
``hci_open_dev`` PyBluez function).
|
||||
:param min_interval: Minimum advertising interval.
|
||||
:param max_interval: Maximum advertising interval.
|
||||
:param adv_type: Advertisement type (``ADV_NONCONN_IND`` by default).
|
||||
:param data: The advertisement data (maximum of 31 bytes).
|
||||
:type data: iterable
|
||||
"""
|
||||
own_bdaddr_type = 0
|
||||
direct_bdaddr_type = 0
|
||||
direct_bdaddr = (0,) * 6
|
||||
chan_map = 0x07 # All channels: 37, 38, 39
|
||||
filter = 0
|
||||
|
||||
struct_params = [min_interval, max_interval, adv_type, own_bdaddr_type,
|
||||
direct_bdaddr_type]
|
||||
struct_params.extend(direct_bdaddr)
|
||||
struct_params.extend((chan_map, filter))
|
||||
|
||||
cmd_pkt = struct.pack("<HHBBB6BBB", *struct_params)
|
||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_ADVERTISING_PARAMETERS,
|
||||
cmd_pkt)
|
||||
|
||||
cmd_pkt = struct.pack("<B", 0x01)
|
||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_ADVERTISE_ENABLE, cmd_pkt)
|
||||
|
||||
data_length = len(data)
|
||||
if data_length > 31:
|
||||
raise ValueError("data is too long (%d but max is 31 bytes)",
|
||||
data_length)
|
||||
cmd_pkt = struct.pack("<B%dB" % data_length, data_length, *data)
|
||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_ADVERTISING_DATA, cmd_pkt)
|
||||
print("Advertising started data_length=%d data=%r" % (data_length, data))
|
||||
|
||||
|
||||
def stop_le_advertising(sock):
|
||||
"""
|
||||
Stop LE advertising.
|
||||
|
||||
:param sock: A bluetooth HCI socket (retrieved using the
|
||||
``hci_open_dev`` PyBluez function).
|
||||
"""
|
||||
cmd_pkt = struct.pack("<B", 0x00)
|
||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_ADVERTISE_ENABLE, cmd_pkt)
|
||||
print("Advertising stopped")
|
||||
|
||||
|
||||
def parse_le_advertising_events(sock, mac_addr=None, packet_length=None,
|
||||
handler=None, debug=False):
|
||||
"""
|
||||
Parse and report LE advertisements.
|
||||
|
||||
This is a blocking call, an infinite loop is started and the
|
||||
given handler will be called each time a new LE advertisement packet
|
||||
is detected and corresponds to the given filters.
|
||||
|
||||
.. note:: The :func:`.start_le_advertising` function must be
|
||||
called before calling this function.
|
||||
|
||||
:param sock: A bluetooth HCI socket (retrieved using the
|
||||
``hci_open_dev`` PyBluez function).
|
||||
:param mac_addr: list of filtered mac address representations
|
||||
(uppercase, with ':' separators).
|
||||
If not specified, the LE advertisement of any device will be reported.
|
||||
Example: mac_addr=('00:2A:5F:FF:25:11', 'DA:FF:12:33:66:12')
|
||||
:type mac_addr: ``list`` of ``string``
|
||||
:param packet_length: Filter a specific length of LE advertisement packet.
|
||||
:type packet_length: ``int``
|
||||
:param handler: Handler that will be called each time a LE advertisement
|
||||
packet is available (in accordance with the ``mac_addr``
|
||||
and ``packet_length`` filters).
|
||||
:type handler: ``callable`` taking 4 parameters:
|
||||
mac (``str``), adv_type (``int``), data (``bytes``) and rssi (``int``)
|
||||
:param debug: Enable debug prints.
|
||||
:type debug: ``bool``
|
||||
"""
|
||||
if not debug and handler is None:
|
||||
raise ValueError("You must either enable debug or give a handler !")
|
||||
|
||||
old_filter = sock.getsockopt(bluez.SOL_HCI, bluez.HCI_FILTER, 14)
|
||||
|
||||
flt = bluez.hci_filter_new()
|
||||
bluez.hci_filter_set_ptype(flt, bluez.HCI_EVENT_PKT)
|
||||
# bluez.hci_filter_all_events(flt)
|
||||
bluez.hci_filter_set_event(flt, LE_META_EVENT)
|
||||
sock.setsockopt(bluez.SOL_HCI, bluez.HCI_FILTER, flt)
|
||||
|
||||
print("socket filter set to ptype=HCI_EVENT_PKT event=LE_META_EVENT")
|
||||
print("Listening ...")
|
||||
|
||||
try:
|
||||
while True:
|
||||
pkt = full_pkt = sock.recv(255)
|
||||
ptype, event, plen = struct.unpack("BBB", pkt[:3])
|
||||
|
||||
if event != LE_META_EVENT:
|
||||
# Should never occur because we filtered with this type of event
|
||||
print("Not a LE_META_EVENT !")
|
||||
continue
|
||||
|
||||
sub_event, = struct.unpack("B", pkt[3:4])
|
||||
if sub_event != EVT_LE_ADVERTISING_REPORT:
|
||||
if debug:
|
||||
print("Not a EVT_LE_ADVERTISING_REPORT !")
|
||||
continue
|
||||
|
||||
pkt = pkt[4:]
|
||||
adv_type = struct.unpack("b", pkt[1:2])[0]
|
||||
mac_addr_str = bluez.ba2str(pkt[3:9])
|
||||
|
||||
if packet_length and plen != packet_length:
|
||||
# ignore this packet
|
||||
if debug:
|
||||
print("packet with non-matching length: mac=%s adv_type=%02x plen=%s" %
|
||||
(mac_addr_str, adv_type, plen))
|
||||
print(raw_packet_to_str(pkt))
|
||||
continue
|
||||
|
||||
data = pkt[9:-1]
|
||||
|
||||
rssi = struct.unpack("b", full_pkt[len(full_pkt)-1:len(full_pkt)])[0]
|
||||
|
||||
if mac_addr and mac_addr_str not in mac_addr:
|
||||
if debug:
|
||||
print("packet with non-matching mac %s adv_type=%02x data=%s RSSI=%s" %
|
||||
(mac_addr_str, adv_type, raw_packet_to_str(data), rssi))
|
||||
continue
|
||||
|
||||
if debug:
|
||||
print("LE advertisement: mac=%s adv_type=%02x data=%s RSSI=%d" %
|
||||
(mac_addr_str, adv_type, raw_packet_to_str(data), rssi))
|
||||
|
||||
if handler is not None:
|
||||
try:
|
||||
handler(mac_addr_str, adv_type, data, rssi)
|
||||
except Exception as e:
|
||||
print('Exception when calling handler with a BLE advertising event: %r' % (e,))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nRestore previous socket filter")
|
||||
sock.setsockopt(bluez.SOL_HCI, bluez.HCI_FILTER, old_filter)
|
||||
raise
|
||||
|
||||
"""
|
||||
def hci_le_add_white_list(int dd, const bdaddr_t *bdaddr, uint8_t type, int to)
|
||||
{
|
||||
struct hci_request {
|
||||
uint16_t ogf;
|
||||
uint16_t ocf;
|
||||
int event;
|
||||
void *cparam;
|
||||
int clen;
|
||||
void *rparam;
|
||||
int rlen;
|
||||
};
|
||||
|
||||
struct hci_request rq;
|
||||
le_add_device_to_white_list_cp cp;
|
||||
uint8_t status;
|
||||
|
||||
memset(&cp, 0, sizeof(cp));
|
||||
cp.bdaddr_type = type;
|
||||
bacpy(&cp.bdaddr, bdaddr);
|
||||
|
||||
memset(&rq, 0, sizeof(rq));
|
||||
rq.ogf = OGF_LE_CTL;
|
||||
rq.ocf = OCF_LE_ADD_DEVICE_TO_WHITE_LIST;
|
||||
rq.cparam = &cp;
|
||||
rq.clen = LE_ADD_DEVICE_TO_WHITE_LIST_CP_SIZE;
|
||||
rq.rparam = &status;
|
||||
rq.rlen = 1;
|
||||
|
||||
if (hci_send_req(dd, &rq, to) < 0)
|
||||
return -1;
|
||||
|
||||
if (status) {
|
||||
errno = EIO;
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}"""
|
||||
10
startup.sh
10
startup.sh
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
tmux start-server
|
||||
|
||||
tmux new-session -d -s sensorer -n sensorer -d 'cd /home/pi/sensorpajen; ./LYWSD03MMC.py -a -wdt 5 --devicelistfile sensorer.ini --callback yes --battery'
|
||||
tmux split-window -t sensorer:0 'cd /home/pi/pirate_audio; ./loop.sh'
|
||||
tmux split-window -t sensorer:0 'cd /home/pi/pirate_audio; ./buttons.py'
|
||||
|
||||
tmux select-layout -t sensorer:0 tiled
|
||||
|
||||
273
systemd/README.md
Normal file
273
systemd/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Systemd Service Installation
|
||||
|
||||
## Installing Sensorpajen as a User Service
|
||||
|
||||
This allows sensorpajen to run automatically on boot as your user (no sudo required for management).
|
||||
|
||||
### Installation Steps
|
||||
|
||||
#### 1. Install the Service File
|
||||
|
||||
```bash
|
||||
cd ~/sensorpajen
|
||||
|
||||
# Create user systemd directory if it doesn't exist
|
||||
mkdir -p ~/.config/systemd/user/
|
||||
|
||||
# Copy service file
|
||||
cp systemd/sensorpajen.service ~/.config/systemd/user/
|
||||
|
||||
# Reload systemd to recognize the new service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
#### 2. Enable Lingering (Run Without Login)
|
||||
|
||||
This allows your user services to run even when you're not logged in:
|
||||
|
||||
```bash
|
||||
# Enable lingering for your user
|
||||
sudo loginctl enable-linger $USER
|
||||
|
||||
# Verify it's enabled
|
||||
loginctl show-user $USER | grep Linger
|
||||
# Should show: Linger=yes
|
||||
```
|
||||
|
||||
#### 3. Start and Enable the Service
|
||||
|
||||
```bash
|
||||
# Start the service now
|
||||
systemctl --user start sensorpajen
|
||||
|
||||
# Enable it to start on boot
|
||||
systemctl --user enable sensorpajen
|
||||
|
||||
# Check status
|
||||
systemctl --user status sensorpajen
|
||||
```
|
||||
|
||||
### Service Management Commands
|
||||
|
||||
```bash
|
||||
# Start the service
|
||||
systemctl --user start sensorpajen
|
||||
|
||||
# Stop the service
|
||||
systemctl --user stop sensorpajen
|
||||
|
||||
# Restart the service
|
||||
systemctl --user restart sensorpajen
|
||||
|
||||
# Check status
|
||||
systemctl --user status sensorpajen
|
||||
|
||||
# View logs (all)
|
||||
journalctl --user -u sensorpajen
|
||||
|
||||
# View logs (follow/tail)
|
||||
journalctl --user -u sensorpajen -f
|
||||
|
||||
# View logs (last 100 lines)
|
||||
journalctl --user -u sensorpajen -n 100
|
||||
|
||||
# View logs (since specific time)
|
||||
journalctl --user -u sensorpajen --since "1 hour ago"
|
||||
journalctl --user -u sensorpajen --since "2025-12-27 10:00"
|
||||
|
||||
# Enable service (start on boot)
|
||||
systemctl --user enable sensorpajen
|
||||
|
||||
# Disable service (don't start on boot)
|
||||
systemctl --user disable sensorpajen
|
||||
```
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
The service logs to systemd journal. View them with:
|
||||
|
||||
```bash
|
||||
# Live view (like tail -f)
|
||||
journalctl --user -u sensorpajen -f
|
||||
|
||||
# With timestamps
|
||||
journalctl --user -u sensorpajen -f -o short-iso
|
||||
|
||||
# Just today's logs
|
||||
journalctl --user -u sensorpajen --since today
|
||||
```
|
||||
|
||||
### Updating the Service
|
||||
|
||||
After making changes to the code:
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
cd ~/sensorpajen
|
||||
git pull origin master
|
||||
|
||||
# Restart the service to apply changes
|
||||
systemctl --user restart sensorpajen
|
||||
|
||||
# Check it started correctly
|
||||
systemctl --user status sensorpajen
|
||||
```
|
||||
|
||||
After editing `sensorpajen.service`:
|
||||
|
||||
```bash
|
||||
# Copy updated service file
|
||||
cp systemd/sensorpajen.service ~/.config/systemd/user/
|
||||
|
||||
# Reload systemd configuration
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# Restart the service
|
||||
systemctl --user restart sensorpajen
|
||||
```
|
||||
|
||||
After editing configuration files:
|
||||
|
||||
```bash
|
||||
# Edit config
|
||||
nano ~/sensorpajen/config/sensorpajen.env
|
||||
# or
|
||||
nano ~/sensorpajen/config/sensors.json
|
||||
|
||||
# Restart service to reload config
|
||||
systemctl --user restart sensorpajen
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
### Permission Denied Errors
|
||||
|
||||
If you see `PermissionError: [Errno 1] Operation not permitted` in the logs:
|
||||
|
||||
```bash
|
||||
# Verify Bluetooth capabilities are set on Python binary
|
||||
getcap ~/.local/share/virtualenvs/*/bin/python3.*
|
||||
|
||||
# If not set, apply capabilities (adjust path to your venv):
|
||||
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
||||
|
||||
# Verify it was set:
|
||||
getcap $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
||||
# Should show: cap_net_admin,cap_net_raw+eip
|
||||
|
||||
# Restart the service
|
||||
systemctl --user restart sensorpajen
|
||||
```
|
||||
|
||||
**Important**: Capabilities must be set on the **actual Python binary**, not symlinks. Use `readlink -f` to resolve the real path.
|
||||
#### Service Won't Start
|
||||
|
||||
```bash
|
||||
# Check detailed status
|
||||
systemctl --user status sensorpajen
|
||||
|
||||
# Check logs for errors
|
||||
journalctl --user -u sensorpajen -n 50
|
||||
|
||||
# Test the command manually
|
||||
cd ~/sensorpajen
|
||||
source .venv/bin/activate
|
||||
export $(cat config/sensorpajen.env | grep -v '^#' | xargs)
|
||||
python -m sensorpajen.main
|
||||
```
|
||||
|
||||
#### Bluetooth Permission Errors
|
||||
|
||||
Make sure capabilities are set on the Python binary:
|
||||
|
||||
```bash
|
||||
getcap $(readlink -f ~/.venv/bin/python3)
|
||||
# Should show: cap_net_raw,cap_net_admin+eip
|
||||
|
||||
# If not set:
|
||||
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
||||
```
|
||||
|
||||
#### Service Doesn't Start on Boot
|
||||
|
||||
```bash
|
||||
# Check if service is enabled
|
||||
systemctl --user is-enabled sensorpajen
|
||||
# Should show: enabled
|
||||
|
||||
# Check if lingering is enabled
|
||||
loginctl show-user $USER | grep Linger
|
||||
# Should show: Linger=yes
|
||||
|
||||
# If not enabled:
|
||||
systemctl --user enable sensorpajen
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
#### Environment Variables Not Loading
|
||||
|
||||
```bash
|
||||
# Verify environment file exists and is readable
|
||||
cat ~/sensorpajen/config/sensorpajen.env
|
||||
|
||||
# Check file permissions
|
||||
ls -la ~/sensorpajen/config/sensorpajen.env
|
||||
|
||||
# Test loading manually
|
||||
export $(cat ~/sensorpajen/config/sensorpajen.env | grep -v '^#' | xargs)
|
||||
env | grep MQTT
|
||||
```
|
||||
|
||||
### Verifying Everything Works
|
||||
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
# 1. Check service is running
|
||||
systemctl --user status sensorpajen
|
||||
|
||||
# 2. Check logs show sensor data
|
||||
journalctl --user -u sensorpajen -f
|
||||
|
||||
# 3. Check MQTT messages are being published
|
||||
mosquitto_sub -h 192.168.0.114 -u hasse -P casablanca -t "MiTemperature2/#" -v
|
||||
|
||||
# 4. Reboot and verify it starts automatically
|
||||
sudo reboot
|
||||
# After reboot:
|
||||
systemctl --user status sensorpajen
|
||||
```
|
||||
|
||||
### Uninstalling the Service
|
||||
|
||||
If you need to remove the service:
|
||||
|
||||
```bash
|
||||
# Stop and disable
|
||||
systemctl --user stop sensorpajen
|
||||
systemctl --user disable sensorpajen
|
||||
|
||||
# Remove service file
|
||||
rm ~/.config/systemd/user/sensorpajen.service
|
||||
|
||||
# Reload systemd
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# Optionally disable lingering
|
||||
sudo loginctl disable-linger $USER
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **User Service**: Runs as your user, not root - more secure and easier to manage
|
||||
- **Lingering**: Required for services to run when not logged in
|
||||
- **Logs**: All output goes to systemd journal (journalctl)
|
||||
- **Auto-restart**: Service restarts automatically on crashes
|
||||
- **Environment**: Config loaded from `config/sensorpajen.env`
|
||||
- **Working Directory**: Service runs from `~/sensorpajen`
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once the service is working:
|
||||
1. Monitor for a few days to ensure stability
|
||||
2. Check logs occasionally: `journalctl --user -u sensorpajen --since yesterday`
|
||||
3. Service will survive reboots and automatically restart on failures
|
||||
32
systemd/sensorpajen.service
Normal file
32
systemd/sensorpajen.service
Normal file
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=Sensorpajen - Bluetooth Temperature Sensor Monitor
|
||||
Documentation=https://github.com/yourusername/sensorpajen
|
||||
After=network.target bluetooth.target
|
||||
Wants=bluetooth.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/sensorpajen
|
||||
EnvironmentFile=%h/sensorpajen/config/sensorpajen.env
|
||||
ExecStart=%h/sensorpajen/.venv/bin/python -m sensorpajen.main
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Bluetooth capabilities (alternative to setcap)
|
||||
# Note: This requires systemd to be run with proper permissions
|
||||
# If this doesn't work, use setcap on the Python binary instead
|
||||
#AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=sensorpajen
|
||||
|
||||
# Security
|
||||
# Note: NoNewPrivileges=true can prevent file capabilities from working
|
||||
# We need capabilities for Bluetooth access, so we can't use it
|
||||
#NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# Copyright (c) 2014 Adafruit Industries
|
||||
# Author: Tony DiCola
|
||||
|
||||
import sys
|
||||
import Adafruit_DHT
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
mqttserver = "192.168.0.114"
|
||||
client = mqtt.Client("koksfonstret")
|
||||
client.connect(mqttserver)
|
||||
|
||||
# Parse command line parameters.
|
||||
sensor_args = { '11': Adafruit_DHT.DHT11,
|
||||
'22': Adafruit_DHT.DHT22,
|
||||
'2302': Adafruit_DHT.AM2302 }
|
||||
if len(sys.argv) == 3 and sys.argv[1] in sensor_args:
|
||||
sensor = sensor_args[sys.argv[1]]
|
||||
pin = sys.argv[2]
|
||||
else:
|
||||
print('Usage: sudo ./Adafruit_DHT.py [11|22|2302] <GPIO pin number>')
|
||||
print('Example: sudo ./Adafruit_DHT.py 2302 4 - Read from an AM2302 connected to GPIO pin #4')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Try to grab a sensor reading. Use the read_retry method which will retry up
|
||||
# to 15 times to get a sensor reading (waiting 2 seconds between each retry).
|
||||
humidity, temperature = Adafruit_DHT.read_retry(sensor, pin)
|
||||
|
||||
# Un-comment the line below to convert the temperature to Fahrenheit.
|
||||
# temperature = temperature * 9/5.0 + 32
|
||||
|
||||
# Note that sometimes you won't get a reading and
|
||||
# the results will be null (because Linux can't
|
||||
# guarantee the timing of calls to read the sensor).
|
||||
# If this happens try again!
|
||||
|
||||
if humidity is not None and temperature is not None:
|
||||
print('Temp={0:0.1f}* Humidity={1:0.1f}%'.format(temperature, humidity))
|
||||
client.publish("casablanca/koksfonstret/temperature", temperature)
|
||||
client.publish("casablanca/koksfonstret/humidity", humidity)
|
||||
|
||||
else:
|
||||
print('Failed to get reading. Try again!')
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user