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 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,11 @@
1
+ [Unit]
2
+ Description=Waveshare PWM Daemon
3
+ After=pigpiod.service
4
+ Requires=pigpiod.service
5
+
6
+ [Service]
7
+ ExecStart=/usr/bin/python3 /usr/local/bin/waveshare_pwm_daemon.py
8
+ Restart=always
9
+
10
+ [Install]
11
+ WantedBy=multi-user.target
@@ -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()