signalk-waveshare-pwm-control 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/index.js +175 -0
- package/package.json +13 -0
- package/waveshare-pwm.service +11 -0
- package/waveshare_pwm_daemon.py +188 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
This plugin is a work in progress to control waveshare displays that are modified for PWM control of the backlight and was mostly developed with the use of ChatGPT.<br>
|
|
2
|
+
<br>
|
|
3
|
+
Details on the display modification can be found at...<br>
|
|
4
|
+
<br>
|
|
5
|
+
https://www.waveshare.com/wiki/10.4HP-CAPQLED</a>
|
|
6
|
+
<br>
|
|
7
|
+
From the FAQ section...<br>
|
|
8
|
+
The default state is to support software adjustment, it is recommended to check the <a href="#Linux_Software_Brightness_Adjustment">#Linux Software Brightness Adjustment</a>, or you can also use the PWM to control the backlight in the following steps:<br>
|
|
9
|
+
<p>As shown below, first remove the original soldered resistor (shown in the red box), and then connect the lower pad of that resistor (shown in the yellow circle) to the GPIO 18 pin of the Raspberry Pi (Physical Pin 12), then you can use GPIO to control the backlight.<br>
|
|
10
|
+
<br>
|
|
11
|
+
<img width="600" height="610" alt="image" src="https://github.com/user-attachments/assets/f90eb62c-4104-40f0-9071-a357c540cb72" />
|
|
12
|
+
<br>
|
|
13
|
+
The daemon needs to be placed in /usr/local/bin and then made executeable by<br>
|
|
14
|
+
sudo chmod +x /usr/local/bin/waveshare_pwm_daemon.py<br>
|
|
15
|
+
then create the systemd service...<br>
|
|
16
|
+
sudo nano /etc/systemd/system/waveshare-pwm.service<br>
|
|
17
|
+
After adding the cotents...<br>
|
|
18
|
+
sudo systemctl daemon-reexec<br>
|
|
19
|
+
sudo systemctl enable waveshare-pwm<br>
|
|
20
|
+
sudo systemctl start waveshare-pwm<br>
|
|
21
|
+
<br>
|
|
22
|
+
Final Notes: The Daemon and service must setup and run on all Pi's connected to the waveshare displays.<br>
|
|
23
|
+
The plan is autonamous dimming based on the sun but with backup by Kip slders. Hope to expand just like the auto B&G display plugin.
|
package/index.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* signalk-waveshare-pwm-control
|
|
3
|
+
*
|
|
4
|
+
* Multi-display PWM control with distributed fallback support
|
|
5
|
+
* Developed with assistance with ChatGPT.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const net = require("net");
|
|
9
|
+
|
|
10
|
+
module.exports = function (app) {
|
|
11
|
+
const plugin = {};
|
|
12
|
+
|
|
13
|
+
let timers = [];
|
|
14
|
+
let unsubscribe = null;
|
|
15
|
+
|
|
16
|
+
function send(display, cmd) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const client = net.createConnection({
|
|
19
|
+
host: display.host,
|
|
20
|
+
port: display.port,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
client.on("connect", () => {
|
|
24
|
+
client.write(JSON.stringify(cmd));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
client.on("data", (data) => {
|
|
28
|
+
try {
|
|
29
|
+
resolve(JSON.parse(data.toString()));
|
|
30
|
+
} catch {
|
|
31
|
+
resolve({});
|
|
32
|
+
}
|
|
33
|
+
client.end();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
client.on("error", reject);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function publish(id, value) {
|
|
41
|
+
app.handleMessage(plugin.id, {
|
|
42
|
+
updates: [
|
|
43
|
+
{
|
|
44
|
+
values: [
|
|
45
|
+
{
|
|
46
|
+
path: `environment.display.${id}.currentBacklightLevel`,
|
|
47
|
+
value,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
plugin.start = function (options) {
|
|
56
|
+
// Initialize each display
|
|
57
|
+
options.displays.forEach((display) => {
|
|
58
|
+
send(display, {
|
|
59
|
+
cmd: "config",
|
|
60
|
+
gamma: display.gamma,
|
|
61
|
+
minBrightness: display.minBrightness,
|
|
62
|
+
debug: options.debug || false,
|
|
63
|
+
}).catch(() => {});
|
|
64
|
+
|
|
65
|
+
const timer = setInterval(async () => {
|
|
66
|
+
try {
|
|
67
|
+
const res = await send(display, { cmd: "get" });
|
|
68
|
+
|
|
69
|
+
if (res && typeof res.value !== "undefined") {
|
|
70
|
+
publish(display.id, res.value);
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (options.debug) {
|
|
74
|
+
app.debug(`Display ${display.id} unreachable`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}, 500);
|
|
78
|
+
|
|
79
|
+
timers.push(timer);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// -----------------------------
|
|
83
|
+
// Signal K v2 Subscription API
|
|
84
|
+
// -----------------------------
|
|
85
|
+
const sub = {
|
|
86
|
+
context: "vessels.self",
|
|
87
|
+
subscribe: [
|
|
88
|
+
{ path: "environment.display.*.backlightLevel" },
|
|
89
|
+
{ path: "environment.sun" },
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
unsubscribe = app.subscriptionmanager.subscribe(sub, (delta) => {
|
|
94
|
+
delta.updates.forEach((update) => {
|
|
95
|
+
update.values.forEach((v) => {
|
|
96
|
+
// Per-display brightness control
|
|
97
|
+
const match = v.path.match(
|
|
98
|
+
/environment\.display\.(\w+)\.backlightLevel/
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (match) {
|
|
102
|
+
const id = match[1];
|
|
103
|
+
const display = options.displays.find((d) => d.id === id);
|
|
104
|
+
|
|
105
|
+
if (display) {
|
|
106
|
+
send(display, { cmd: "set", value: v.value }).catch(() => {});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Global sun control
|
|
111
|
+
if (v.path === "environment.sun") {
|
|
112
|
+
options.displays.forEach((display) => {
|
|
113
|
+
if (!display.sun) return;
|
|
114
|
+
|
|
115
|
+
const target = display.sun[v.value];
|
|
116
|
+
if (typeof target !== "undefined") {
|
|
117
|
+
send(display, { cmd: "set", value: target }).catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
plugin.stop = function () {
|
|
127
|
+
timers.forEach(clearInterval);
|
|
128
|
+
timers = [];
|
|
129
|
+
|
|
130
|
+
if (typeof unsubscribe === "function") {
|
|
131
|
+
unsubscribe();
|
|
132
|
+
unsubscribe = null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
plugin.id = "signalk-waveshare-pwm-control";
|
|
137
|
+
plugin.name = "Waveshare PWM Multi Display Control";
|
|
138
|
+
plugin.description =
|
|
139
|
+
"Distributed PWM backlight control with fallback (Signal K v2 API)";
|
|
140
|
+
|
|
141
|
+
plugin.schema = {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
debug: {
|
|
145
|
+
type: "boolean",
|
|
146
|
+
title: "Enable Debug Logging",
|
|
147
|
+
default: false,
|
|
148
|
+
},
|
|
149
|
+
displays: {
|
|
150
|
+
type: "array",
|
|
151
|
+
title: "Displays",
|
|
152
|
+
items: {
|
|
153
|
+
type: "object",
|
|
154
|
+
properties: {
|
|
155
|
+
id: { type: "string", title: "Display ID (max 3 chars)" },
|
|
156
|
+
host: { type: "string", title: "IP Address" },
|
|
157
|
+
port: { type: "number", default: 8765 },
|
|
158
|
+
gamma: { type: "number", default: 2.2 },
|
|
159
|
+
minBrightness: { type: "number", default: 5 },
|
|
160
|
+
sun: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {
|
|
163
|
+
day: { type: "number", default: 90 },
|
|
164
|
+
night: { type: "number", default: 20 },
|
|
165
|
+
twilight: { type: "number", default: 50 },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return plugin;
|
|
175
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "signalk-waveshare-pwm-control",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Waveshare PWM backlight control",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": ["signalk-node-server-plugin"],
|
|
7
|
+
"author": "Rhumb Runner (Chuck Bowers) & ChatGPT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/Rhumb-Runner/waveshare-pwm-control"
|
|
11
|
+
},
|
|
12
|
+
"license": "Apache-2.0"
|
|
13
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
waveshare_pwm_daemon.py
|
|
4
|
+
|
|
5
|
+
Features:
|
|
6
|
+
- Hardware PWM via pigpio
|
|
7
|
+
- TCP control
|
|
8
|
+
- Smooth fades
|
|
9
|
+
- Gamma correction
|
|
10
|
+
- Distributed fallback (Signal K watchdog)
|
|
11
|
+
|
|
12
|
+
Developed with assistance from ChatGPT.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import socket
|
|
16
|
+
import os
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import threading
|
|
20
|
+
import pigpio
|
|
21
|
+
import math
|
|
22
|
+
|
|
23
|
+
GPIO_PIN = 18
|
|
24
|
+
PWM_FREQ = 10000
|
|
25
|
+
PWM_RANGE = 1024
|
|
26
|
+
|
|
27
|
+
DEFAULT_BRIGHTNESS = 80
|
|
28
|
+
|
|
29
|
+
MIN_FADE = 1.0
|
|
30
|
+
MAX_FADE = 3.0
|
|
31
|
+
UPDATE_RATE = 30.0
|
|
32
|
+
|
|
33
|
+
GAMMA = 2.2
|
|
34
|
+
MIN_BRIGHTNESS = 5
|
|
35
|
+
DEBUG = False
|
|
36
|
+
|
|
37
|
+
TCP_PORT = 8765
|
|
38
|
+
BIND_ADDRESS = "0.0.0.0"
|
|
39
|
+
|
|
40
|
+
# Fallback config
|
|
41
|
+
SIGNALK_SERVER = "10.42.0.1"
|
|
42
|
+
SIGNALK_PORT = 80
|
|
43
|
+
HEARTBEAT_INTERVAL = 2
|
|
44
|
+
TIMEOUT = 6 # seconds before fallback
|
|
45
|
+
|
|
46
|
+
FALLBACK_MODE = "hold" # "hold" or "dim"
|
|
47
|
+
FALLBACK_BRIGHTNESS = 40
|
|
48
|
+
|
|
49
|
+
def log(msg):
|
|
50
|
+
if DEBUG:
|
|
51
|
+
print("[PWM]", msg)
|
|
52
|
+
|
|
53
|
+
class PWMDaemon:
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.pi = pigpio.pi()
|
|
56
|
+
if not self.pi.connected:
|
|
57
|
+
raise RuntimeError("pigpiod not running")
|
|
58
|
+
|
|
59
|
+
self.current_percent = DEFAULT_BRIGHTNESS
|
|
60
|
+
self.target_percent = DEFAULT_BRIGHTNESS
|
|
61
|
+
|
|
62
|
+
self.last_command_time = time.time()
|
|
63
|
+
self.in_fallback = False
|
|
64
|
+
|
|
65
|
+
self.lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
self.pi.set_PWM_frequency(GPIO_PIN, PWM_FREQ)
|
|
68
|
+
self.pi.set_PWM_range(GPIO_PIN, PWM_RANGE)
|
|
69
|
+
|
|
70
|
+
self.apply_pwm(self.current_percent)
|
|
71
|
+
|
|
72
|
+
threading.Thread(target=self.fade_loop, daemon=True).start()
|
|
73
|
+
threading.Thread(target=self.watchdog_loop, daemon=True).start()
|
|
74
|
+
|
|
75
|
+
def gamma_correct(self, percent):
|
|
76
|
+
if percent <= 0:
|
|
77
|
+
return 0
|
|
78
|
+
return int(math.pow(percent / 100.0, GAMMA) * PWM_RANGE)
|
|
79
|
+
|
|
80
|
+
def apply_pwm(self, percent):
|
|
81
|
+
if percent > 0:
|
|
82
|
+
percent = max(percent, MIN_BRIGHTNESS)
|
|
83
|
+
|
|
84
|
+
pwm = self.gamma_correct(percent)
|
|
85
|
+
self.pi.set_PWM_dutycycle(GPIO_PIN, pwm)
|
|
86
|
+
|
|
87
|
+
def set_target(self, percent):
|
|
88
|
+
percent = max(0, min(100, percent))
|
|
89
|
+
|
|
90
|
+
with self.lock:
|
|
91
|
+
self.last_command_time = time.time()
|
|
92
|
+
self.in_fallback = False
|
|
93
|
+
|
|
94
|
+
self.start_percent = self.current_percent
|
|
95
|
+
self.target_percent = percent
|
|
96
|
+
|
|
97
|
+
delta = abs(percent - self.current_percent)
|
|
98
|
+
duration = max(MIN_FADE, MAX_FADE * (delta / 100.0))
|
|
99
|
+
|
|
100
|
+
self.fade_steps = int(duration * UPDATE_RATE)
|
|
101
|
+
self.step = 0
|
|
102
|
+
|
|
103
|
+
def ease(self, t):
|
|
104
|
+
return 0.5 * (1 - math.cos(math.pi * t))
|
|
105
|
+
|
|
106
|
+
def fade_loop(self):
|
|
107
|
+
while True:
|
|
108
|
+
time.sleep(1.0 / UPDATE_RATE)
|
|
109
|
+
|
|
110
|
+
with self.lock:
|
|
111
|
+
if self.current_percent == self.target_percent:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
t = self.step / self.fade_steps if self.fade_steps else 1
|
|
115
|
+
eased = self.ease(t)
|
|
116
|
+
|
|
117
|
+
self.current_percent = (
|
|
118
|
+
self.start_percent +
|
|
119
|
+
(self.target_percent - self.start_percent) * eased
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self.step += 1
|
|
123
|
+
if self.step >= self.fade_steps:
|
|
124
|
+
self.current_percent = self.target_percent
|
|
125
|
+
|
|
126
|
+
self.apply_pwm(self.current_percent)
|
|
127
|
+
|
|
128
|
+
def watchdog_loop(self):
|
|
129
|
+
while True:
|
|
130
|
+
time.sleep(HEARTBEAT_INTERVAL)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
socket.create_connection((SIGNALK_SERVER, SIGNALK_PORT), 1).close()
|
|
134
|
+
alive = True
|
|
135
|
+
except:
|
|
136
|
+
alive = False
|
|
137
|
+
|
|
138
|
+
if not alive and (time.time() - self.last_command_time) > TIMEOUT:
|
|
139
|
+
if not self.in_fallback:
|
|
140
|
+
log("Entering fallback mode")
|
|
141
|
+
self.in_fallback = True
|
|
142
|
+
|
|
143
|
+
if FALLBACK_MODE == "dim":
|
|
144
|
+
self.set_target(FALLBACK_BRIGHTNESS)
|
|
145
|
+
|
|
146
|
+
def handle(self, conn):
|
|
147
|
+
try:
|
|
148
|
+
msg = json.loads(conn.recv(1024).decode())
|
|
149
|
+
|
|
150
|
+
if msg["cmd"] == "set":
|
|
151
|
+
self.set_target(msg["value"])
|
|
152
|
+
conn.send(b'{"ok":true}')
|
|
153
|
+
|
|
154
|
+
elif msg["cmd"] == "get":
|
|
155
|
+
conn.send(json.dumps({
|
|
156
|
+
"value": self.current_percent
|
|
157
|
+
}).encode())
|
|
158
|
+
|
|
159
|
+
elif msg["cmd"] == "config":
|
|
160
|
+
global GAMMA, MIN_BRIGHTNESS, DEBUG
|
|
161
|
+
GAMMA = msg.get("gamma", GAMMA)
|
|
162
|
+
MIN_BRIGHTNESS = msg.get("minBrightness", MIN_BRIGHTNESS)
|
|
163
|
+
DEBUG = msg.get("debug", DEBUG)
|
|
164
|
+
conn.send(b'{"ok":true}')
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
conn.send(json.dumps({"error": str(e)}).encode())
|
|
168
|
+
finally:
|
|
169
|
+
conn.close()
|
|
170
|
+
|
|
171
|
+
def tcp_server(self):
|
|
172
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
173
|
+
s.bind((BIND_ADDRESS, TCP_PORT))
|
|
174
|
+
s.listen(5)
|
|
175
|
+
|
|
176
|
+
while True:
|
|
177
|
+
conn, _ = s.accept()
|
|
178
|
+
threading.Thread(target=self.handle, args=(conn,), daemon=True).start()
|
|
179
|
+
|
|
180
|
+
def run(self):
|
|
181
|
+
threading.Thread(target=self.tcp_server, daemon=True).start()
|
|
182
|
+
|
|
183
|
+
while True:
|
|
184
|
+
time.sleep(1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
PWMDaemon().run()
|