feat: Visual upgrades, Dallas sensor backend, and docs

This commit is contained in:
2026-01-14 14:44:01 +01:00
commit 7a79e48378
11 changed files with 1291 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# Python
__pycache__/
*.py[cod]
*$py.class
# Virtual Environment
.venv/
venv/
env/
# Distribution / Build
build/
dist/
*.spec
# Logs
*.log

154
README.md Normal file
View File

@@ -0,0 +1,154 @@
# Volvo Display App
## Setup
1. Install Python dependencies:
```bash
pip install -r requirements.txt
```
Or if using system packages:
```bash
sudo apt install python3-pyside6
```
2. **System Requirements (Linux/Raspberry Pi):**
PySide6 (Qt 6.5+) requires `libxcb-cursor0` to be installed on Linux systems.
```bash
sudo apt install libxcb-cursor0
```
If you encounter other missing library errors, you may need additional Qt dependencies:
```bash
sudo apt install libxcb-xinerama0
```
## Hardware Setup (Dallas DS18B20 Sensors)
To use real temperature sensors, wire them to the Raspberry Pi as follows:
| Sensor Wire | Raspberry Pi Pin | Note |
| :--- | :--- | :--- |
| **VCC (Red)** | **Pin 1 (3.3V)** | |
| **GND (Black)** | **Pin 6 (GND)** | |
| **DATA (Yellow)** | **Pin 7 (GPIO 4)** | **Requires 4.7kΩ Resistor between VCC and DATA** |
**Enable 1-Wire**:
1. Run `sudo raspi-config`
2. Select **Interface Options** -> **1-Wire** -> **Yes**.
3. Reboot.
### Option B: Separate Pins (Advanced)
If you prefer to connect sensors to separate pins (e.g., to distinguish them physically or avoid soldering), you can enable multiple 1-Wire buses.
1. Open configuration:
```bash
sudo nano /boot/config.txt
```
2. Add multiple overlay lines with defined pins (e.g., GPIO 4 and GPIO 17):
```ini
dtoverlay=w1-gpio,gpiopin=4
dtoverlay=w1-gpio,gpiopin=17
```
3. Reboot.
*Note: Sensors from all pins will still appear in the same list. Identification is done via their unique ID.*
## Running the App
Run the application with:
```bash
python3 main.py
```
### Headless / Framebuffer
If running on a Raspberry Pi without a desktop environment, you might need to specify the platform:
```bash
python3 main.py -platform linuxfb
```
or
```bash
python3 main.py -platform eglfs
```
## Running on Headless Raspberry Pi
If the application hangs or fails to open on the display, ensure you are using the correct platform plugin.
Use the provided helper script:
```bash
chmod +x run_pi.sh
./run_pi.sh
```
Or manually:
```bash
export QT_QPA_PLATFORM=eglfs
python3 main.py
# or if using the compiled binary:
./dist/volvodisplay
```
### Troubleshooting "failed to load egl device"
This error means the application cannot access the Direct Rendering Manager (DRM) device required for hardware acceleration (`eglfs`).
1. **Check Permissions**: Ensure your user is part of the `render` and `video` groups.
```bash
sudo usermod -aG render $USER
sudo usermod -aG video $USER
```
**You must reboot** after running these commands.
2. **Use Software Rendering**: If you cannot get hardware acceleration working, use `linuxfb` (Linux Framebuffer). This uses the CPU.
./run_pi.sh linuxfb
```
3. **Check Boot Configuration**:
If you are running as root and still get EGL errors, ensure your Pi is using the KMS driver.
Check `/boot/config.txt` (or `/boot/firmware/config.txt`) and ensure this line is active:
```ini
dtoverlay=vc4-kms-v3d
```
(or `dtoverlay=vc4-fkms-v3d` for legacy fake-kms)
4. **Runtime Directory**:
Qt requires `XDG_RUNTIME_DIR` to be set. The `run_pi.sh` script now handles this automatically for root users.
```bash
export QT_QPA_PLATFORM=linuxfb
python3 main.py
```
## Compiling for Raspberry Pi
### System Requirements for Build
PyInstaller needs to locate system libraries to bundle them. On a fresh Raspberry Pi OS (or Debian container), you likely need to install these:
```bash
sudo apt update
sudo apt install -y \
libxcb-cursor0 \
libxcb-icccm4 \
libxcb-keysyms1 \
libxcb-render-util0 \
libxcb-xkb1 \
libxcb-image0 \
libxkbcommon-x11-0 \
libxkbcommon0
```
### Build Instructions
To create a single-file binary that runs on the Raspberry Pi:
1. Ensure you are **on the Raspberry Pi** (or a system with the same architecture, e.g., ARM64).
2. Make the build script executable:
```bash
chmod +x build.sh
```
3. Run the build script:
```bash
./build.sh
```
4. The executable will be located in the `dist` folder:
```bash
./dist/volvodisplay
```

721
TemperatureGauge.qml Normal file
View File

@@ -0,0 +1,721 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Shapes
Item {
id: root
width: 480
height: 480
property real currentTemp: 0 // Bound in main.qml
onCurrentTempChanged: console.log("QML Left Temp Updated: " + currentTemp)
property real currentRightTemp: 0 // Bound in main.qml
property bool startupComplete: true
// Sweep Property for Startup - REMOVED
// property real sweepTemp: -15
// Startup Sweep Animation - REMOVED
// SequentialAnimation { ... }
Component.onCompleted: {
forceActiveFocus()
root.startupComplete = true
}
Rectangle {
id: background
anchors.fill: parent
// color: "#050505" // Old solid color
// Gradient for depth (Vertical Linear)
gradient: Gradient {
GradientStop { position: 0.0; color: "black" }
GradientStop { position: 0.5; color: "#1a1a1a" } // Lighter center strip
GradientStop { position: 1.0; color: "black" }
}
}
// --- Left Gauge (Temperature) ---
Item {
id: leftGauge
anchors.fill: parent
// Track Outline
Shape {
anchors.fill: parent
// Anti-aliasing for smoother lines
// layer.enabled: true
// layer.samples: 4
// layer.smooth: true
ShapePath {
strokeColor: "white"
strokeWidth: 2
fillColor: "transparent"
capStyle: ShapePath.FlatCap
// Outer Arc (145 to 215 degrees)
// 145 deg = 0.8055 PI
// 215 deg = 1.1944 PI
startX: 240 + 210 * Math.cos(Math.PI * (145/180))
startY: 240 + 210 * Math.sin(Math.PI * (145/180))
PathArc {
x: 240 + 210 * Math.cos(Math.PI * (215/180))
y: 240 + 210 * Math.sin(Math.PI * (215/180))
radiusX: 210; radiusY: 210
useLargeArc: false
}
// Bottom Line connecting to inner
PathLine {
x: 240 + 160 * Math.cos(Math.PI * (215/180))
y: 240 + 160 * Math.sin(Math.PI * (215/180))
}
// Inner Arc
PathArc {
x: 240 + 160 * Math.cos(Math.PI * (145/180))
y: 240 + 160 * Math.sin(Math.PI * (145/180))
radiusX: 160; radiusY: 160
useLargeArc: false
direction: PathArc.Counterclockwise
}
// Top Line connecting back to start
PathLine {
x: 240 + 210 * Math.cos(Math.PI * (145/180))
y: 240 + 210 * Math.sin(Math.PI * (145/180))
}
}
}
// Indicator (Blue Bar)
Shape {
anchors.fill: parent
// layer.enabled: true
// layer.samples: 4
ShapePath {
strokeColor: "transparent"
fillColor: "#2196F3" // Blue
// Range -15 to 0.
// -15 = 145 deg
// 0 = 171.25 deg
startX: 240 + 205 * Math.cos(Math.PI * (145/180))
startY: 240 + 205 * Math.sin(Math.PI * (145/180))
PathArc {
x: 240 + 205 * Math.cos(Math.PI * (171.25/180))
y: 240 + 205 * Math.sin(Math.PI * (171.25/180))
radiusX: 205; radiusY: 205
}
PathLine {
x: 240 + 165 * Math.cos(Math.PI * (171.25/180))
y: 240 + 165 * Math.sin(Math.PI * (171.25/180))
}
PathArc {
x: 240 + 165 * Math.cos(Math.PI * (145/180))
y: 240 + 165 * Math.sin(Math.PI * (145/180))
radiusX: 165; radiusY: 165
direction: PathArc.Counterclockwise
}
PathLine {
x: 240 + 205 * Math.cos(Math.PI * (145/180))
y: 240 + 205 * Math.sin(Math.PI * (145/180))
}
}
}
// Temperature Markers & Ticks
Shape {
anchors.fill: parent
// layer.enabled: true
// layer.samples: 4
ShapePath {
strokeColor: "white"
strokeWidth: 2
fillColor: "transparent"
// -15 @ 145 deg
startX: 240 + 210 * Math.cos(Math.PI * (145/180))
startY: 240 + 210 * Math.sin(Math.PI * (145/180))
PathLine { x: 240 + 215 * Math.cos(Math.PI * (145/180)); y: 240 + 215 * Math.sin(Math.PI * (145/180)) }
// -10 @ 153.75 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (153.75/180)); y: 240 + 210 * Math.sin(Math.PI * (153.75/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (153.75/180)); y: 240 + 215 * Math.sin(Math.PI * (153.75/180)) }
// -5 @ 162.5 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (162.5/180)); y: 240 + 210 * Math.sin(Math.PI * (162.5/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (162.5/180)); y: 240 + 215 * Math.sin(Math.PI * (162.5/180)) }
// 0 @ 171.25 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (171.25/180)); y: 240 + 210 * Math.sin(Math.PI * (171.25/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (171.25/180)); y: 240 + 215 * Math.sin(Math.PI * (171.25/180)) }
// 5 @ 180 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * 1.0); y: 240 + 210 * Math.sin(Math.PI * 1.0) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * 1.0); y: 240 + 215 * Math.sin(Math.PI * 1.0) }
// 10 @ 188.75 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (188.75/180)); y: 240 + 210 * Math.sin(Math.PI * (188.75/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (188.75/180)); y: 240 + 215 * Math.sin(Math.PI * (188.75/180)) }
// 15 @ 197.5 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (197.5/180)); y: 240 + 210 * Math.sin(Math.PI * (197.5/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (197.5/180)); y: 240 + 215 * Math.sin(Math.PI * (197.5/180)) }
// 20 @ 206.25 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (206.25/180)); y: 240 + 210 * Math.sin(Math.PI * (206.25/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (206.25/180)); y: 240 + 215 * Math.sin(Math.PI * (206.25/180)) }
// 25 @ 215 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (215/180)); y: 240 + 210 * Math.sin(Math.PI * (215/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (215/180)); y: 240 + 215 * Math.sin(Math.PI * (215/180)) }
}
}
// -15 @ 145 deg
Text {
text: "-15"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (145/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (145/180)) - height/2
}
// -10 @ 153.75 deg
Text {
text: "-10"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (153.75/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (153.75/180)) - height/2
}
// -5 @ 162.5 deg
Text {
text: "-5"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (162.5/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (162.5/180)) - height/2
}
// 0 @ 171.25 deg
Text {
text: "0"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (171.25/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (171.25/180)) - height/2
}
// 5 @ 180 deg
Text {
text: "5"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * 1.0) - width/2
y: 240 + 230 * Math.sin(Math.PI * 1.0) - height/2
}
// 10 @ 188.75 deg
Text {
text: "10"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (188.75/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (188.75/180)) - height/2
}
// 15 @ 197.5 deg
Text {
text: "15"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (197.5/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (197.5/180)) - height/2
}
// 20 @ 206.25 deg
Text {
text: "20"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (206.25/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (206.25/180)) - height/2
}
// 25 @ 215 deg
Text {
text: "25"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (215/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (215/180)) - height/2
}
}
// --- Right Gauge (Mirrored) ---
Item {
id: rightGauge
anchors.fill: parent
// Track Outline
Shape {
anchors.fill: parent
// layer.enabled: true
// layer.samples: 4
// layer.smooth: true
ShapePath {
strokeColor: "white"
strokeWidth: 2
fillColor: "transparent"
capStyle: ShapePath.FlatCap
// Outer Arc (35 to -35 degrees)
// 35 deg = 0.1944 PI
// -35 deg = 325 deg = 1.8055 PI
startX: 240 + 210 * Math.cos(Math.PI * (325/180))
startY: 240 + 210 * Math.sin(Math.PI * (325/180))
PathArc {
x: 240 + 210 * Math.cos(Math.PI * (35/180))
y: 240 + 210 * Math.sin(Math.PI * (35/180))
radiusX: 210; radiusY: 210
useLargeArc: false
}
// Bottom Line connecting to inner
PathLine {
x: 240 + 160 * Math.cos(Math.PI * (35/180))
y: 240 + 160 * Math.sin(Math.PI * (35/180))
}
// Inner Arc
PathArc {
x: 240 + 160 * Math.cos(Math.PI * (325/180))
y: 240 + 160 * Math.sin(Math.PI * (325/180))
radiusX: 160; radiusY: 160
useLargeArc: false
direction: PathArc.Counterclockwise
}
// Top Line connecting back to start
PathLine {
x: 240 + 210 * Math.cos(Math.PI * (325/180))
y: 240 + 210 * Math.sin(Math.PI * (325/180))
}
}
}
// Indicator (Blue Bar)
Shape {
anchors.fill: parent
// layer.enabled: true
// layer.samples: 4
ShapePath {
strokeColor: "transparent"
fillColor: "#2196F3" // Blue
// Range -15 to 0.
// -15 = 35 deg
// 0 = 8.75 deg
startX: 240 + 205 * Math.cos(Math.PI * (35/180))
startY: 240 + 205 * Math.sin(Math.PI * (35/180))
PathArc {
x: 240 + 205 * Math.cos(Math.PI * (8.75/180))
y: 240 + 205 * Math.sin(Math.PI * (8.75/180))
radiusX: 205; radiusY: 205
direction: PathArc.Counterclockwise
}
PathLine {
x: 240 + 165 * Math.cos(Math.PI * (8.75/180))
y: 240 + 165 * Math.sin(Math.PI * (8.75/180))
}
PathArc {
x: 240 + 165 * Math.cos(Math.PI * (35/180))
y: 240 + 165 * Math.sin(Math.PI * (35/180))
radiusX: 165; radiusY: 165
}
PathLine {
x: 240 + 205 * Math.cos(Math.PI * (35/180))
y: 240 + 205 * Math.sin(Math.PI * (35/180))
}
}
}
// Markers & Ticks
Shape {
anchors.fill: parent
// layer.enabled: true
// layer.samples: 4
ShapePath {
strokeColor: "white"
strokeWidth: 2
fillColor: "transparent"
// -15 @ 35 deg
startX: 240 + 210 * Math.cos(Math.PI * (35/180))
startY: 240 + 210 * Math.sin(Math.PI * (35/180))
PathLine { x: 240 + 215 * Math.cos(Math.PI * (35/180)); y: 240 + 215 * Math.sin(Math.PI * (35/180)) }
// -10 @ 26.25 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (26.25/180)); y: 240 + 210 * Math.sin(Math.PI * (26.25/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (26.25/180)); y: 240 + 215 * Math.sin(Math.PI * (26.25/180)) }
// -5 @ 17.5 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (17.5/180)); y: 240 + 210 * Math.sin(Math.PI * (17.5/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (17.5/180)); y: 240 + 215 * Math.sin(Math.PI * (17.5/180)) }
// 0 @ 8.75 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (8.75/180)); y: 240 + 210 * Math.sin(Math.PI * (8.75/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (8.75/180)); y: 240 + 215 * Math.sin(Math.PI * (8.75/180)) }
// 5 @ 0 deg
PathMove { x: 240 + 210 * Math.cos(0); y: 240 + 210 * Math.sin(0) }
PathLine { x: 240 + 215 * Math.cos(0); y: 240 + 215 * Math.sin(0) }
// 10 @ -8.75 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (-8.75/180)); y: 240 + 210 * Math.sin(Math.PI * (-8.75/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (-8.75/180)); y: 240 + 215 * Math.sin(Math.PI * (-8.75/180)) }
// 15 @ -17.5 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (-17.5/180)); y: 240 + 210 * Math.sin(Math.PI * (-17.5/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (-17.5/180)); y: 240 + 215 * Math.sin(Math.PI * (-17.5/180)) }
// 20 @ -26.25 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (-26.25/180)); y: 240 + 210 * Math.sin(Math.PI * (-26.25/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (-26.25/180)); y: 240 + 215 * Math.sin(Math.PI * (-26.25/180)) }
// 25 @ -35 deg
PathMove { x: 240 + 210 * Math.cos(Math.PI * (-35/180)); y: 240 + 210 * Math.sin(Math.PI * (-35/180)) }
PathLine { x: 240 + 215 * Math.cos(Math.PI * (-35/180)); y: 240 + 215 * Math.sin(Math.PI * (-35/180)) }
}
}
// -15 @ 35 deg
Text {
text: "-15"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (35/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (35/180)) - height/2
}
// -10 @ 26.25 deg
Text {
text: "-10"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (26.25/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (26.25/180)) - height/2
}
// -5 @ 17.5 deg
Text {
text: "-5"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (17.5/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (17.5/180)) - height/2
}
// 0 @ 8.75 deg
Text {
text: "0"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (8.75/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (8.75/180)) - height/2
}
// 5 @ 0 deg
Text {
text: "5"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(0) - width/2
y: 240 + 230 * Math.sin(0) - height/2
}
// 10 @ -8.75 deg
Text {
text: "10"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (-8.75/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (-8.75/180)) - height/2
}
// 15 @ -17.5 deg
Text {
text: "15"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (-17.5/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (-17.5/180)) - height/2
}
// 20 @ -26.25 deg
Text {
text: "20"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (-26.25/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (-26.25/180)) - height/2
}
// 25 @ -35 deg
Text {
text: "25"
color: "white"
font.pixelSize: 20
font.family: "Arial"
font.bold: true
x: 240 + 230 * Math.cos(Math.PI * (-35/180)) - width/2
y: 240 + 230 * Math.sin(Math.PI * (-35/180)) - height/2
}
}
// --- Center Cover Shape ---
Shape {
anchors.fill: parent
// layer.enabled: true
// layer.samples: 4
z: 1 // Above background, below text/needles
ShapePath {
strokeColor: "#333333" // Subtle outline
strokeWidth: 1
fillColor: "#111111" // Slightly lighter than black
// Top Arc (225 to 315 degrees)
startX: 240 + 210 * Math.cos(Math.PI * 1.25) // 225 deg
startY: 240 + 210 * Math.sin(Math.PI * 1.25)
PathArc {
x: 240 + 210 * Math.cos(Math.PI * 1.75) // 315 deg
y: 240 + 210 * Math.sin(Math.PI * 1.75)
radiusX: 210; radiusY: 210
}
// Right Side (Straight lines)
// Line to Top-Right Waist
PathLine {
x: 240 + 90
y: 240 - 80
}
// Vertical Line down
PathLine {
x: 240 + 90
y: 240 + 80
}
// Line to Bottom Arc Start (45 deg)
PathLine {
x: 240 + 210 * Math.cos(Math.PI * 0.25)
y: 240 + 210 * Math.sin(Math.PI * 0.25)
}
// Bottom Arc (45 to 135 degrees)
PathArc {
x: 240 + 210 * Math.cos(Math.PI * 0.75) // 135 deg
y: 240 + 210 * Math.sin(Math.PI * 0.75)
radiusX: 210; radiusY: 210
}
// Left Side (Straight lines)
// Line to Bottom-Left Waist
PathLine {
x: 240 - 90
y: 240 + 80
}
// Vertical Line up
PathLine {
x: 240 - 90
y: 240 - 80
}
// Line back to Top Arc Start (225 deg)
PathLine {
x: 240 + 210 * Math.cos(Math.PI * 1.25)
y: 240 + 210 * Math.sin(Math.PI * 1.25)
}
}
// Inner Glow / Highlight on the cover
ShapePath {
strokeColor: "transparent"
fillColor: "transparent" // Placeholder for an effect if needed
}
}
// --- Center Details ---
Text {
text: "VOLVO"
font.pixelSize: 28
font.bold: true
font.family: "Serif"
color: "white"
anchors.bottom: parent.bottom
anchors.bottomMargin: 100
anchors.horizontalCenter: parent.horizontalCenter
z: 2 // Ensure on top of cover
}
// Unit Symbol (Moved to root to be above center cover)
Text {
text: "°C"
color: "orange"
font.pixelSize: 28
font.family: "Arial"
font.bold: true
z: 10 // Above center cover
anchors.centerIn: parent
anchors.verticalCenterOffset: -40 // Above center
}
// --- Needles ---
component GaugeNeedle : Item {
id: needleComponent
property real angle: 0
property color needleColor: "#FF6600"
x: 240
y: 240
width: 1
height: 1
// Glow/Shadow effect behind needle
Rectangle {
width: 180
height: 12
color: needleComponent.needleColor
opacity: 0.3
radius: 6
y: -height / 2
x: 0
transform: Rotation {
origin.x: 0
origin.y: 6
angle: needleComponent.angle
}
}
Rectangle {
id: needleRect
width: 180
height: 4 // Thinner for sharper look
color: needleComponent.needleColor
radius: 2
antialiasing: true
y: -height / 2
x: 0 // Starts at center and extends outwards
transform: Rotation {
origin.x: 0
origin.y: needleRect.height / 2
angle: needleComponent.angle
}
}
}
function getAngleFromTemp(temp) {
// Range -15 to 25 (40 deg range)
// Span 70 degrees (145 to 215)
// 70 / 40 = 1.75 degrees per unit
return 145 + (temp + 15) * 1.75
}
function getRightAngleFromTemp(temp) {
// Range -15 to 25
// Span 70 degrees (35 to -35)
return 35 - (temp + 15) * 1.75
}
// Keyboard Control
focus: true
Keys.onUpPressed: {
if (root.startupComplete) {
root.currentTemp = Math.min(root.currentTemp + 2, 25)
}
}
Keys.onDownPressed: {
if (root.startupComplete) {
root.currentTemp = Math.max(root.currentTemp - 2, -15)
}
}
Keys.onPressed: (event) => {
if (!root.startupComplete) return;
if (event.key === Qt.Key_PageUp) {
root.currentRightTemp = Math.min(root.currentRightTemp + 2, 25)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.currentRightTemp = Math.max(root.currentRightTemp - 2, -15)
event.accepted = true
}
}
GaugeNeedle {
id: leftNeedle
// If startup complete, use real temp. Else use sweep temp.
angle: root.getAngleFromTemp(root.startupComplete ? root.currentTemp : -15)
Behavior on angle {
// Slower, smoother damping for a "heavy" gauge feel
SpringAnimation { spring: 2; damping: 0.2; epsilon: 0.1 }
}
}
GaugeNeedle {
id: rightNeedle
angle: root.getRightAngleFromTemp(root.startupComplete ? root.currentRightTemp : -15)
Behavior on angle {
SpringAnimation { spring: 2; damping: 0.2; epsilon: 0.1 }
}
}
}

35
build.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# Source virtual environment if it exists
if [ -d ".venv" ]; then
source .venv/bin/activate
elif [ -d "venv" ]; then
source venv/bin/activate
fi
# Install dependencies if not already installed
pip install -r requirements.txt
# Run PyInstaller
# --name: Name of the binary
# --onefile: Create a single executable file
# --windowed: No console window (useful for GUI apps)
# --add-data: Include main.qml in the binary
# --clean: Clean cache
# --noconfirm: overwrite existing build directory
echo "Building volvo_display binary..."
pyinstaller --name "volvodisplay" \
--onefile \
--windowed \
--clean \
--noconfirm \
--add-data "main.qml:." \
--add-data "TemperatureGauge.qml:." \
--exclude-module PySide6.QtWebEngineQuick \
--exclude-module PySide6.QtWebEngineCore \
--exclude-module PySide6.QtQuick3DSpatialAudio \
main.py
echo "Build complete. Binary is in 'dist/volvodisplay'"

4
config.json Normal file
View File

@@ -0,0 +1,4 @@
{
"left_sensor_id": "020391774d86",
"right_sensor_id": "0213138491aa"
}

172
main.py Normal file
View File

@@ -0,0 +1,172 @@
import sys
import os
import json
import math
from PySide6.QtGui import QGuiApplication
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtCore import QObject, Property, Signal, Slot, QTimer
# Try importing w1thermsensor, handle failure for dev environments
try:
from w1thermsensor import W1ThermSensor, Sensor
HAS_W1 = True
except ImportError:
HAS_W1 = False
print("Warning: w1thermsensor not found. Running in simulation mode.")
class Backend(QObject):
leftTempChanged = Signal()
rightTempChanged = Signal()
def __init__(self, demo_mode=False):
super().__init__()
self.demo_mode = demo_mode
self._left_temp = 0.0
self._right_temp = 0.0
self.left_sensor = None
self.right_sensor = None
# Load Config
self.config = self.load_config()
# Initialize Sensors
self.init_sensors()
# Timer for polling
self.timer = QTimer()
self.timer.timeout.connect(self.update_temps)
self.timer.start(2000) # Poll every 2 seconds
def load_config(self):
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
if os.path.exists(config_path):
try:
with open(config_path, 'r') as f:
return json.load(f)
except Exception as e:
print(f"Error loading config: {e}")
return {}
def init_sensors(self):
if not HAS_W1:
print("W1ThermSensor not installed.")
return
print(f"Loading sensors using config: {self.config}")
try:
available_sensors = W1ThermSensor.get_available_sensors()
print(f"Available W1 sensors: {[s.id for s in available_sensors]}")
# Map Left Sensor
left_id = self.config.get("left_sensor_id")
if left_id:
try:
# Check if ID exists in available to be sure
found = next((s for s in available_sensors if s.id == left_id), None)
if found:
self.left_sensor = found
print(f"✅ Successfully bound Left Sensor to {left_id}")
else:
print(f"❌ Configured Left Sensor {left_id} not found in available list!")
# Try direct init anyway just in case
self.left_sensor = W1ThermSensor(sensor_id=left_id)
except Exception as e:
print(f"❌ Failed to bind Left Sensor {left_id}: {e}")
# Auto-assign fallback
if not self.left_sensor and len(available_sensors) > 0:
self.left_sensor = available_sensors[0]
print(f"⚠️ Auto-assigned Left Sensor to {self.left_sensor.id}")
# Map Right Sensor
right_id = self.config.get("right_sensor_id")
if right_id:
try:
self.right_sensor = W1ThermSensor(sensor_id=right_id)
print(f"Bound Right Sensor to {right_id}")
except Exception as e:
print(f"Failed to bind Right Sensor {right_id}: {e}")
except Exception as e:
print(f"Error initializing sensors: {e}")
def update_temps(self):
# Simulation counter
if not hasattr(self, '_sim_step'):
self._sim_step = 0
self._sim_step += 0.1
# Left
if self.left_sensor:
try:
t = self.left_sensor.get_temperature()
print(f"Left Sensor ({self.left_sensor.id}) Read: {t}°C")
self._set_left_temp(t)
except Exception as e:
print(f"Error reading left sensor: {e}")
elif self.demo_mode:
# Simulation
print("Left Sensor Missing - Simulating...")
sim_val = 5 + 10 * math.sin(self._sim_step)
self._set_left_temp(sim_val)
# Right
if self.right_sensor:
try:
t = self.right_sensor.get_temperature()
self._set_right_temp(t)
except Exception as e:
print(f"Error reading right sensor: {e}")
elif self.demo_mode:
# Simulation
sim_val = 10 + 10 * math.cos(self._sim_step)
self._set_right_temp(sim_val)
@Property(float, notify=leftTempChanged)
def leftTemp(self):
return self._left_temp
def _set_left_temp(self, val):
if abs(self._left_temp - val) > 0.1:
self._left_temp = val
self.leftTempChanged.emit()
@Property(float, notify=rightTempChanged)
def rightTemp(self):
return self._right_temp
def _set_right_temp(self, val):
if abs(self._right_temp - val) > 0.1:
self._right_temp = val
self.rightTempChanged.emit()
def main():
# Redirect output to log.txt
sys.stdout = open("log.txt", "w", buffering=1)
sys.stderr = sys.stdout
print("--- Volvo Display Log ---")
# Check for demo mode
demo_mode = "--demo" in sys.argv
if demo_mode:
print("Starting in DEMO MODE (Simulation Enabled)")
app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
backend = Backend(demo_mode=demo_mode)
engine.rootContext().setContextProperty("backend", backend)
# Get absolute path to main.qml
current_dir = os.path.dirname(os.path.abspath(__file__))
qml_file = os.path.join(current_dir, "main.qml")
engine.load(qml_file)
if not engine.rootObjects():
sys.exit(-1)
sys.exit(app.exec())
if __name__ == "__main__":
main()

63
main.qml Normal file
View File

@@ -0,0 +1,63 @@
import QtQuick
import QtQuick.Controls
Window {
width: 480
height: 480
visible: true
title: "Volvo Display"
// For a circular display, we might want to hide the window frame
// if running full screen, but for now we keep it standard.
// flags: Qt.FramelessWindowHint
Loader {
id: mainLoader
anchors.fill: parent
sourceComponent: introComponent
onStatusChanged: {
if (status === Loader.Ready) console.log("Loader: Loaded " + source)
if (status === Loader.Error) console.log("Loader: Error loading " + source)
}
Binding {
target: mainLoader.item
property: "currentTemp"
value: backend.leftTemp
when: mainLoader.status === Loader.Ready && mainLoader.source.toString().includes("TemperatureGauge.qml")
}
Binding {
target: mainLoader.item
property: "currentRightTemp"
value: backend.rightTemp
when: mainLoader.status === Loader.Ready && mainLoader.source.toString().includes("TemperatureGauge.qml")
}
}
Component {
id: introComponent
Rectangle {
id: bg
anchors.fill: parent
color: "white"
Text {
anchors.centerIn: parent
text: "Hello Pi"
color: "black"
}
}
}
Timer {
interval: 5000
running: true
repeat: false
onTriggered: {
console.log("Timer triggered. Switching to TemperatureGauge.qml")
mainLoader.source = "TemperatureGauge.qml"
}
}
}

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
PySide6
pyinstaller
w1thermsensor

63
run_pi.sh Normal file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Ensure we are in the script's directory
cd "$(dirname "$0")"
# Activate venv if it exists
if [ -f "venv/bin/activate" ]; then
source venv/bin/activate
fi
# Configuration for Raspberry Pi Headless Display (EGLFS/KMS)
# Default to eglfs, but allow override
# Usage: ./run_pi.sh [platform]
# Example: ./run_pi.sh linuxfb
# Initialize from argument or default
# Check if running under X11 (DISPLAY is set)
if [ -n "$DISPLAY" ]; then
echo "X11 environment detected (DISPLAY=$DISPLAY). Using xcb."
TARGET_PLATFORM="xcb"
else
# If DISPLAY is not set, use the provided argument or default to linuxfb
TARGET_PLATFORM=${1:-linuxfb}
fi
# Diagnostic: Check for DRI devices
if [ -z "$(ls /dev/dri/card* 2>/dev/null)" ] && [ "$TARGET_PLATFORM" == "eglfs" ]; then
echo "WARNING: /dev/dri/card0 not found. Hardware acceleration (EGLFS) will likely fail."
echo "Check /boot/config.txt for 'dtoverlay=vc4-kms-v3d'."
echo "Switching to linuxfb..."
TARGET_PLATFORM=linuxfb
fi
if [ "$TARGET_PLATFORM" == "linuxfb" ]; then
export QSG_RENDER_LOOP=basic
fi
export QT_QPA_PLATFORM=$TARGET_PLATFORM
# Fix for "EGLFS: Failed to open /dev/dri/card0" or similar when running as root/headless
# Qt needs a runtime directory
if [ -z "$XDG_RUNTIME_DIR" ]; then
export XDG_RUNTIME_DIR=/tmp/runtime-root
mkdir -p $XDG_RUNTIME_DIR
chmod 700 $XDG_RUNTIME_DIR
fi
# For Raspberry Pi 4 with KMS (vc4-kms-v3d)
export QT_QPA_EGLFS_INTEGRATION=eglfs_kms
export QT_QPA_EGLFS_ALWAYS_SET_MODE=1
# export QT_LOGGING_RULES=qt.qpa.*=true
# export QSGRENDERER_DEBUG=1
echo "Starting Volvo Display with platform: $TARGET_PLATFORM"
if [ -f "./dist/volvodisplay" ]; then
./dist/volvodisplay
else
python3 main.py
fi

58
verify_sensors.py Normal file
View File

@@ -0,0 +1,58 @@
import time
import sys
try:
from w1thermsensor import W1ThermSensor
except ImportError:
print("Error: w1thermsensor module not found.")
print("Please install it using: pip install w1thermsensor")
sys.exit(1)
def main():
print("--- 1-Wire Temperature Sensor Verifier ---")
print("Checking for connected sensors...")
try:
sensors = W1ThermSensor.get_available_sensors()
except Exception as e:
print(f"Error accessing 1-Wire bus: {e}")
print("Ensure 1-Wire is enabled in raspi-config or /boot/config.txt")
return
if not sensors:
print("No sensors found!")
print("Check your wiring:")
print(" 1. VCC (Red) -> Pin 1 (3.3V)")
print(" 2. GND (Black) -> Pin 6 (GND)")
print(" 3. DATA (Yellow) -> Pin 7 (GPIO 4)")
print(" * Remember the 4.7k resistor between VCC and DATA")
return
print(f"Found {len(sensors)} sensor(s):")
for i, sensor in enumerate(sensors):
try:
print(f" {i+1}: ID={sensor.id} (Type={getattr(sensor, 'type_name', 'Unknown')})")
except Exception as e:
print(f" {i+1}: Error reading sensor details: {e}")
print(f" Debug info: {dir(sensor)}")
print("\nReading temperatures (Press Ctrl+C to stop)...")
try:
while True:
output = []
for sensor in sensors:
try:
temp = sensor.get_temperature()
output.append(f"[{sensor.id}]: {temp:.1f}°C")
except Exception as e:
output.append(f"[{sensor.id}]: Error")
# Print on same line to act like a dashboard
print(" | ".join(output), end="\r")
sys.stdout.flush()
time.sleep(1)
except KeyboardInterrupt:
print("\nStopped.")
if __name__ == "__main__":
main()

Binary file not shown.