Skip to content

Commit b296036

Browse files
feat: add Sense HAT integration module
1 parent b0c5924 commit b296036

File tree

6 files changed

+467
-7
lines changed

6 files changed

+467
-7
lines changed

config/config.js.sample

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,31 @@ let config = {
104104
broadcastNewsUpdates: true
105105
}
106106
},
107+
// Example Sense HAT module configuration:
108+
// {
109+
// module: "sensehat",
110+
// position: "top_right",
111+
// config: {
112+
// updateInterval: 5000,
113+
// showTemperature: true,
114+
// showHumidity: true,
115+
// showPressure: true,
116+
// showOrientation: false,
117+
// temperatureUnit: "C",
118+
// roundValues: 1,
119+
// ledMatrixEnabled: true,
120+
// ledMode: "status", // "off" | "status" | "text"
121+
// ledText: "Hello from Sense HAT",
122+
// ledColor: [0, 255, 0],
123+
// criticalThresholds: {
124+
// temperatureHigh: 30,
125+
// temperatureLow: 10,
126+
// humidityHigh: 80,
127+
// humidityLow: 20
128+
// },
129+
// debug: false
130+
// }
131+
// },
107132
]
108133
};
109134

modules/default/sensehat/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
Sense HAT Module for MagicMirror²
2+
3+
This module integrates the Raspberry Pi Sense HAT with MagicMirror². It reads sensor data and can control the 8×8 LED matrix for simple status indication or text display.
4+
5+
Features
6+
7+
- Display temperature, humidity, pressure, and optionally orientation (pitch/roll/yaw)
8+
- Periodic polling interval configurable
9+
- Optional LED matrix control: off, status color, or scrolling text
10+
- Simple threshold-based LED status (green/normal, red/out-of-range)
11+
12+
Requirements
13+
14+
- Raspberry Pi with an attached Sense HAT
15+
- Python 3 and the official Sense HAT library
16+
17+
Install dependencies on the Raspberry Pi:
18+
sudo apt update
19+
sudo apt install -y sense-hat python3-sense-hat
20+
21+
Installation
22+
23+
1. Copy the modules/default/sensehat folder into your MagicMirror installation on the Raspberry Pi (or keep it in your repo and deploy it).
24+
2. Ensure the Python helper is executable (optional):
25+
- chmod +x modules/default/sensehat/python/reader.py
26+
27+
Configuration
28+
Add the module to your config/config.js:
29+
30+
{
31+
module: "sensehat",
32+
position: "top_right",
33+
config: {
34+
updateInterval: 5000,
35+
showTemperature: true,
36+
showHumidity: true,
37+
showPressure: true,
38+
showOrientation: false,
39+
temperatureUnit: "C",
40+
roundValues: 1,
41+
ledMatrixEnabled: true,
42+
ledMode: "status", // "off" | "status" | "text"
43+
ledText: "Hello from Sense HAT",
44+
ledColor: [0, 255, 0],
45+
criticalThresholds: {
46+
temperatureHigh: 30,
47+
temperatureLow: 10,
48+
humidityHigh: 80,
49+
humidityLow: 20
50+
},
51+
debug: false
52+
}
53+
}
54+
55+
How it works
56+
57+
- Frontend (sensehat.js): Displays data and optionally sends LED status commands based on thresholds.
58+
- Node Helper (node_helper.js): Spawns the Python helper to read sensors at a set interval and forwards LED commands to it.
59+
- Python Helper (python/reader.py): Uses from sense_hat import SenseHat to read sensors and control the LED matrix. Outputs JSON to stdout in read mode.
60+
61+
Troubleshooting
62+
63+
- If you see warnings about "sense_hat library not available", ensure the package python3-sense-hat is installed and you are on a Raspberry Pi.
64+
- If LED text rendering blocks updates, remember that show_message scrolls the text; it will finish and allow the next poll. Reduce updateInterval or avoid text mode if you prefer continuous updates.
65+
66+
License
67+
MIT (follow the MagicMirror² project license)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
const { spawn } = require("child_process");
2+
const path = require("path");
3+
const NodeHelper = require("node_helper");
4+
const Log = require("logger");
5+
6+
module.exports = NodeHelper.create({
7+
start () {
8+
this.config = null;
9+
this.updateTimer = null;
10+
this.pythonPath = "python3"; // most Pis use python3
11+
this.readerPath = path.join(__dirname, "python", "reader.py");
12+
Log.info("[sensehat] Node helper started");
13+
},
14+
15+
stop () {
16+
if (this.updateTimer) {
17+
clearInterval(this.updateTimer);
18+
this.updateTimer = null;
19+
}
20+
},
21+
22+
socketNotificationReceived (notification, payload) {
23+
if (notification === "SENSEHAT_CONFIG") {
24+
this.config = payload || {};
25+
Log.info("[sensehat] Configuration received");
26+
this._setupPolling();
27+
28+
// optional: set initial LED state
29+
if (this.config.ledMatrixEnabled) {
30+
this._handleLedCommand({ mode: this.config.ledMode || "status", color: this.config.ledColor || [0, 255, 0], text: this.config.ledText || "" });
31+
}
32+
} else if (notification === "SENSEHAT_LED_COMMAND") {
33+
this._handleLedCommand(payload || {});
34+
}
35+
},
36+
37+
_setupPolling () {
38+
if (this.updateTimer) {
39+
clearInterval(this.updateTimer);
40+
this.updateTimer = null;
41+
}
42+
const interval = Math.max(1000, parseInt(this.config.updateInterval || 5000, 10));
43+
// Poll immediately, then on interval
44+
this._pollOnce();
45+
this.updateTimer = setInterval(() => this._pollOnce(), interval);
46+
Log.info(`[sensehat] Polling every ${interval} ms`);
47+
},
48+
49+
_pollOnce () {
50+
const args = [this.readerPath, "--read"]; // explicit --read
51+
const child = spawn(this.pythonPath, args, { cwd: path.dirname(this.readerPath) });
52+
53+
let stdout = "";
54+
let stderr = "";
55+
child.stdout.on("data", (d) => (stdout += d.toString()));
56+
child.stderr.on("data", (d) => (stderr += d.toString()));
57+
child.on("error", (err) => {
58+
Log.error(`[sensehat] Failed to spawn Python: ${err.message}`);
59+
});
60+
61+
child.on("close", (code) => {
62+
if (stderr && (this.config && this.config.debug)) {
63+
Log.warn(`[sensehat] python stderr: ${stderr.trim()}`);
64+
}
65+
if (code !== 0 && !stdout) {
66+
Log.warn(`[sensehat] Python exited with code ${code}`);
67+
return;
68+
}
69+
try {
70+
const data = JSON.parse(stdout.trim());
71+
if (data && data.error) {
72+
Log.warn(`[sensehat] Python reported error: ${data.error}`);
73+
return;
74+
}
75+
this.sendSocketNotification("SENSEHAT_DATA", data);
76+
} catch (e) {
77+
Log.warn(`[sensehat] Invalid JSON from python: ${e.message}. Raw: ${stdout.trim()}`);
78+
}
79+
});
80+
},
81+
82+
_handleLedCommand (cmd) {
83+
if (!this.config || !this.config.ledMatrixEnabled) return;
84+
85+
const args = [this.readerPath];
86+
if (cmd.clear || cmd.mode === "off") {
87+
args.push("--clear");
88+
} else if (cmd.mode === "text") {
89+
args.push("--mode", "text");
90+
if (cmd.text) {
91+
args.push("--text", String(cmd.text));
92+
}
93+
const color = Array.isArray(cmd.color) ? cmd.color : this.config.ledColor || [255, 255, 255];
94+
args.push("--color", color.join(","));
95+
} else {
96+
// status mode
97+
args.push("--mode", "status");
98+
const color = Array.isArray(cmd.color) ? cmd.color : this.config.ledColor || [0, 255, 0];
99+
args.push("--color", color.join(","));
100+
}
101+
102+
const child = spawn(this.pythonPath, args, { cwd: path.dirname(this.readerPath) });
103+
let stderr = "";
104+
child.stderr.on("data", (d) => (stderr += d.toString()));
105+
child.on("close", (code) => {
106+
if (code !== 0) {
107+
Log.warn(`[sensehat] LED command failed with code ${code}. ${stderr.trim()}`);
108+
} else if (this.config && this.config.debug && stderr.trim()) {
109+
Log.warn(`[sensehat] LED command stderr: ${stderr.trim()}`);
110+
}
111+
});
112+
}
113+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import json
4+
import sys
5+
from datetime import datetime, timezone
6+
7+
try:
8+
from sense_hat import SenseHat
9+
except Exception as e:
10+
SenseHat = None
11+
12+
13+
def iso_timestamp():
14+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
15+
16+
17+
def read_sensors():
18+
if SenseHat is None:
19+
return {"error": "sense_hat library not available"}
20+
try:
21+
sh = SenseHat()
22+
temperature = sh.get_temperature()
23+
humidity = sh.get_humidity()
24+
pressure = sh.get_pressure()
25+
26+
# Orientation
27+
orientation = sh.get_orientation() or {}
28+
pitch = orientation.get("pitch")
29+
roll = orientation.get("roll")
30+
yaw = orientation.get("yaw")
31+
32+
# IMU sensors
33+
accel_raw = sh.get_accelerometer_raw() or {}
34+
gyro_raw = sh.get_gyroscope_raw() or {}
35+
mag_raw = sh.get_compass_raw() or {}
36+
37+
return {
38+
"temperature": float(temperature) if temperature is not None else None,
39+
"humidity": float(humidity) if humidity is not None else None,
40+
"pressure": float(pressure) if pressure is not None else None,
41+
"orientation": {
42+
"pitch": float(pitch) if pitch is not None else None,
43+
"roll": float(roll) if roll is not None else None,
44+
"yaw": float(yaw) if yaw is not None else None,
45+
},
46+
"accelerometer": {
47+
"x": float(accel_raw.get("x")) if accel_raw.get("x") is not None else None,
48+
"y": float(accel_raw.get("y")) if accel_raw.get("y") is not None else None,
49+
"z": float(accel_raw.get("z")) if accel_raw.get("z") is not None else None,
50+
},
51+
"gyroscope": {
52+
"x": float(gyro_raw.get("x")) if gyro_raw.get("x") is not None else None,
53+
"y": float(gyro_raw.get("y")) if gyro_raw.get("y") is not None else None,
54+
"z": float(gyro_raw.get("z")) if gyro_raw.get("z") is not None else None,
55+
},
56+
"magnetometer": {
57+
"x": float(mag_raw.get("x")) if mag_raw.get("x") is not None else None,
58+
"y": float(mag_raw.get("y")) if mag_raw.get("y") is not None else None,
59+
"z": float(mag_raw.get("z")) if mag_raw.get("z") is not None else None,
60+
},
61+
"timestamp": iso_timestamp(),
62+
}
63+
except Exception as e:
64+
return {"error": f"Exception during sensor read: {e}"}
65+
66+
67+
def parse_color(s):
68+
parts = [p.strip() for p in s.split(",")]
69+
if len(parts) != 3:
70+
raise ValueError("Color must be r,g,b")
71+
rgb = []
72+
for p in parts:
73+
v = int(p)
74+
if v < 0:
75+
v = 0
76+
if v > 255:
77+
v = 255
78+
rgb.append(v)
79+
return tuple(rgb)
80+
81+
82+
def apply_led(args):
83+
if SenseHat is None:
84+
print("sense_hat library not available", file=sys.stderr)
85+
return 1
86+
try:
87+
sh = SenseHat()
88+
if args.clear:
89+
sh.clear()
90+
return 0
91+
if args.mode == "text":
92+
color = parse_color(args.color) if args.color else (255, 255, 255)
93+
sh.clear()
94+
sh.show_message(args.text or "", text_colour=color, scroll_speed=0.07)
95+
return 0
96+
# default status mode
97+
color = parse_color(args.color) if args.color else (0, 255, 0)
98+
sh.clear(color)
99+
return 0
100+
except Exception as e:
101+
print(f"LED command failed: {e}", file=sys.stderr)
102+
return 2
103+
104+
105+
def main():
106+
parser = argparse.ArgumentParser(description="Sense HAT reader and LED controller")
107+
parser.add_argument("--read", action="store_true", help="Read sensors and output JSON")
108+
parser.add_argument("--mode", choices=["status", "text"], help="LED mode")
109+
parser.add_argument("--text", help="Text to display when mode=text")
110+
parser.add_argument("--color", help="Color as r,g,b")
111+
parser.add_argument("--clear", action="store_true", help="Clear LED matrix")
112+
113+
args = parser.parse_args()
114+
115+
if args.read or (not args.mode and not args.clear):
116+
data = read_sensors()
117+
print(json.dumps(data))
118+
return 0 if "error" not in data else 3
119+
120+
# Otherwise LED control
121+
return apply_led(args)
122+
123+
124+
if __name__ == "__main__":
125+
sys.exit(main())

0 commit comments

Comments
 (0)