commit 7a79e48378e5af8f742e4301303d8331e9da2199 Author: Daniel Dybing Date: Wed Jan 14 14:44:01 2026 +0100 feat: Visual upgrades, Dallas sensor backend, and docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09bb192 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Virtual Environment +.venv/ +venv/ +env/ + +# Distribution / Build +build/ +dist/ +*.spec + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd7c579 --- /dev/null +++ b/README.md @@ -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 + ``` diff --git a/TemperatureGauge.qml b/TemperatureGauge.qml new file mode 100644 index 0000000..4b6b9d4 --- /dev/null +++ b/TemperatureGauge.qml @@ -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 } + } + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..aa1e255 --- /dev/null +++ b/build.sh @@ -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'" diff --git a/config.json b/config.json new file mode 100644 index 0000000..e834325 --- /dev/null +++ b/config.json @@ -0,0 +1,4 @@ +{ + "left_sensor_id": "020391774d86", + "right_sensor_id": "0213138491aa" +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..47b4b03 --- /dev/null +++ b/main.py @@ -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() diff --git a/main.qml b/main.qml new file mode 100644 index 0000000..ddd8f18 --- /dev/null +++ b/main.qml @@ -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" + } + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f02e31e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PySide6 +pyinstaller +w1thermsensor + diff --git a/run_pi.sh b/run_pi.sh new file mode 100644 index 0000000..0f02856 --- /dev/null +++ b/run_pi.sh @@ -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 diff --git a/verify_sensors.py b/verify_sensors.py new file mode 100644 index 0000000..0d6b053 --- /dev/null +++ b/verify_sensors.py @@ -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() diff --git a/volvodisplay_splashscreen.kra b/volvodisplay_splashscreen.kra new file mode 100644 index 0000000..16181ca Binary files /dev/null and b/volvodisplay_splashscreen.kra differ