@uncaught/gpio-shutter-bridge 1.0.0

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,115 @@
1
+ # MQTT shutter bridge for home assistant or alike
2
+
3
+ This is an implementation of a bridge between shutter controls and home assistant.
4
+
5
+ In particular I have connected one Velux KLF 150 gateway and one Schellenberg shutter remote to my Raspberry Pi using the GPIO pins.
6
+
7
+ But you can use this for Velux only, of course. You will need 3 GPIO pins for each Velux shutter and you can use up to 5 shutters with the KLF 150 in this setup.
8
+
9
+ **No soldering** required if you stick with Velux only - everything is just screws.
10
+
11
+ ## Features
12
+
13
+ - Supports Velux KLF 150 or any 3-button remote control
14
+ - MQTT interface with auto-discovery for home assistant
15
+ - Positioning of the Velux shutters (time-based)
16
+
17
+ ## Flow chart
18
+
19
+ ```mermaid
20
+ flowchart LR
21
+ HA[Home Assistant/MQTT] <--> Bridge[THIS LIBRARY]
22
+ Bridge <--> GPIO[Raspberry Pi GPIOs]
23
+ GPIO --> Relays[Relay Modules]
24
+ Relays --> KLF[Velux KLF 150]
25
+ Relays --> Remote[Schellenberg Remote]
26
+ KLF <--> Velux[Velux Shutters]
27
+ KLF --> GPIO
28
+ Remote --> Balcony[Balcony Shutter]
29
+
30
+ style Bridge fill:#ff9900,stroke:#333,stroke-width:2px,color:#fff
31
+ ```
32
+
33
+ # Hardware
34
+
35
+ ## Before unboxing
36
+
37
+ This is how everything looks before "unboxing" it into a more permanent shape:
38
+
39
+ ![finished cabling](docs/cable-sallad.png)
40
+
41
+ ## My hardware
42
+
43
+ Just to give you an insight, you can pobably use this project with any other hardware. It could make sense to buy a complete ESP32 GPIO board instead of using a Raspberry Pi.
44
+
45
+ - 1 Schellenberg shutter motor in my balcony door
46
+ - 1 Schellenberg remote control
47
+ - Which I opened up and soldered contacts to all three buttons (up/stop/down) as well as the power supply.
48
+ - This way I can supply power directly via the 3V GPIO output of the raspberry pi without needing batteries.
49
+ - I'm trusting you with this [definitely professional piece of art](docs/schellenberg.png).
50
+ - 3 Velux covers/shutters on my roof windows
51
+ - Velux KLF 150 gateway
52
+ - See https://www.velux.com/klf150
53
+ - Before buying this, I also tried soldering contacts to the Velux remotes (model 3UR B01 WW), but I broke the first...
54
+ - 9 relay modules
55
+ - If you use a Raspberry PI, you need relays that work with 3V and it helps if they already have a pull-up resistor because the GPIO pins can have a floating voltage when the PI boots and they are not yet assigned as outputs, triggering the relay when it shouldn't.
56
+ - I bought [this 10 pack on amazon](https://www.amazon.de/dp/B0F53QDMXG)
57
+ - You will need 2 relays per Velux shutter, so for my three windows I needed 6 relays.
58
+ - Additionally I needed 3 relays for my Schellenberg remote control.
59
+ - If you find a board with multiple 3V relays on it, go ahead. I just screwed mine together on a wooden board.
60
+ - Raspberry Pi Model B
61
+ - Yes, the first version of the Raspberry Pi with the single armv6 CPU!
62
+ - Still had this lying around, so why waste it.
63
+ - I got node 21 running on it for this project.
64
+ - One USB power supply for the Raspberry Pi
65
+ - I used one of my many 2A supplies, not the 1.4A one that comes with the Velux KLF 150.
66
+ - The Pi then powers the relays, the KLF 150 and the Schellenberg remote.
67
+
68
+ ## Wiring GPIOs to the relays
69
+
70
+ ![Relay boards](docs/relay-boards.png)
71
+
72
+ - Connect all **VCC** pins of the relays to a 3V GPIO pin of the Raspberry Pi.
73
+ - I have daisy chained them together. Only the most left relay is connected to the Pi.
74
+ - Connect all **GND** pins of the relays to a GND pin of the Raspberry Pi.
75
+ - Again daisy chained.
76
+ - Connect each **IN** pin of the relays to one GPIO pin of the Raspberry Pi.
77
+ - If you use all 10 relays, for all 5 shutters, I'm [suggesting](./example.ts) pins [2, 3, 4, 17, 27, 22, 10, 9, 11, 8](https://pinout.xyz/).
78
+
79
+ ## Wiring the Velux KLF 150 to the rest
80
+
81
+ ![Velux KLF 150 sockets](docs/velux-klf150-sockets.png)
82
+
83
+ This is for the default configuration as described in the Velux KLF 150 manual. Meaning: You have 2 output pins and 4 input pins per shutter (A, B, C, D, E). For each shutter, the first input pin makes the shutter open, the second input pin makes it close, and both together make the shutter stop. The output pins for each shutter should close on success (also the default behavior).
84
+
85
+ ### Outputs
86
+
87
+ - Connect all 5 bottom pins of the output (A-E) together and to GND.
88
+ - Connect all 5 top pins of the output (A-E) to any GPIO pin.
89
+ - I'm [suggesting](./example.ts) pins [14, 15, 18, 23, 24](https://pinout.xyz/).
90
+
91
+ ### Inputs
92
+
93
+ - The bottom line of all input pins are for GND. However, since the cabling comes in pairs anyway and you need to connect them each to one of the relays, I just used all the existing cables.
94
+ - So connect all 10 inputs to the 10 relays, the top one to **NO** (normally open) and the bottom one to **COM** (common).
95
+
96
+ # Software
97
+
98
+ - Create a javascript file, require my library and call it with your shutter-pin-layout:
99
+
100
+ ```js
101
+ import {createVeluxShutters, initRuntime, initMqtt} from '@uncaught/gpio-shutter-bridge';
102
+
103
+ const {onDispose} = initRuntime();
104
+
105
+ onDispose(initMqtt(createVeluxShutters([
106
+ {ident: 'Velux_A', up: 2, down: 3, input: 14},
107
+ {ident: 'Velux_B', up: 4, down: 17, input: 15},
108
+ {ident: 'Velux_C', up: 27, down: 22, input: 18},
109
+ {ident: 'Velux_D', up: 10, down: 9, input: 23},
110
+ {ident: 'Velux_E', up: 11, down: 8, input: 24}, //same row!
111
+ ], onDispose), {url: 'mqtt://your-mqtt-or-home-assistant'}));
112
+ ```
113
+
114
+ - The ident should match `/[a-zA-Z][a-zA-Z0-9_-]*/`.
115
+ - See [my personal example](./example.ts) for a few more details.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { createVeluxShutters, initRuntime, initMqtt, createThreeButtonShutter } from './src/index.js';
2
+ /**
3
+ * This is an example for connecting to a Velux KLF150 with up to 5 shutters.
4
+ * The up/down pins are all aligned on the left GPIO column and the inputs are on the right, except for shutter e.
5
+ */
6
+ const veluxKlf150 = [
7
+ { ident: 'Velux_A', up: 2, down: 3, input: 14 },
8
+ { ident: 'Velux_B', up: 4, down: 17, input: 15 },
9
+ { ident: 'Velux_C', up: 27, down: 22, input: 18 },
10
+ { ident: 'Velux_D', up: 10, down: 9, input: 23 },
11
+ { ident: 'Velux_E', up: 11, down: 8, input: 24 }, //same row!
12
+ ];
13
+ /**
14
+ * When using exactly 3 shutters, using the CDE socket with the 6x2 plug makes more sense:
15
+ */
16
+ const veluxKlf150CDE = [
17
+ { ...veluxKlf150[0], ident: 'Velux_C' },
18
+ { ...veluxKlf150[1], ident: 'Velux_D' },
19
+ { ...veluxKlf150[2], ident: 'Velux_E' },
20
+ ];
21
+ /**
22
+ * This runtime implementation is also just an example - you can use your own implementation if you want.
23
+ */
24
+ const { onDispose } = initRuntime();
25
+ /**
26
+ * For my personal use, I have 3 shutters tied to the Velux KLF150 and another shutter controlled with
27
+ * a 3-button remote, which I hacked and connected to the GPIO-pins 10/9/11:
28
+ */
29
+ const shutters = [
30
+ ...createVeluxShutters(veluxKlf150CDE, onDispose),
31
+ createThreeButtonShutter('Balkon', 10, 9, 11, onDispose),
32
+ ];
33
+ onDispose(initMqtt(shutters, { url: 'mqtts://mosquitto.local.correnz.net', rejectUnauthorized: false }));
@@ -0,0 +1,5 @@
1
+ import { Gpio } from 'onoff';
2
+ import type { OnDispose } from './runtime.js';
3
+ export declare function press(...outputs: readonly Gpio[]): Promise<void>;
4
+ export declare function mkOutput(pin: number, onDispose: OnDispose): Gpio;
5
+ export declare function mkInput(pin: number, onDispose: OnDispose): Gpio;
@@ -0,0 +1,32 @@
1
+ import { Gpio } from 'onoff';
2
+ import { execSync } from 'child_process';
3
+ async function sleep(wait) {
4
+ await new Promise(resolve => setTimeout(resolve, wait));
5
+ }
6
+ export async function press(...outputs) {
7
+ outputs.forEach(output => output.writeSync(1));
8
+ await sleep(200);
9
+ outputs.forEach(output => output.writeSync(0));
10
+ }
11
+ export function mkOutput(pin, onDispose) {
12
+ const gpio = new Gpio(pin, 'out');
13
+ onDispose(() => {
14
+ gpio.writeSync(0);
15
+ gpio.unexport();
16
+ });
17
+ return gpio;
18
+ }
19
+ export function mkInput(pin, onDispose) {
20
+ //Setting to pull-up - the `onoff`-library doesn't do that.
21
+ // I'm doing this because the inputs were floating high without any pull, so I just stick with it.
22
+ // We assume the input pin is closed to ground, causing it to change to low.
23
+ execSync(`raspi-gpio set ${pin} pu`);
24
+ const gpio = new Gpio(pin, 'in', 'both');
25
+ onDispose(() => gpio.unexport());
26
+ //Double check that the input is high:
27
+ const value = gpio.readSync();
28
+ if (value !== Gpio.HIGH) {
29
+ console.error(`Expected GPIO ${pin} to be high, but it was ${value}`);
30
+ }
31
+ return gpio;
32
+ }
@@ -0,0 +1,25 @@
1
+ export declare const shutterPositions: readonly ["open", "closed", "in-between", "unknown"];
2
+ export type ShutterPosition = typeof shutterPositions[number];
3
+ export declare const shutterActions: readonly ["opening", "closing", "stopping"];
4
+ export type ShutterAction = typeof shutterActions[number];
5
+ export declare const shutterStates: readonly ["open", "closed", "in-between", "unknown", "opening", "closing", "stopping"];
6
+ export type ShutterState = typeof shutterStates[number];
7
+ export declare function isShutterPosition(state: unknown): state is ShutterPosition;
8
+ export declare function isShutterAction(state: unknown): state is ShutterAction;
9
+ export interface ShutterInterface {
10
+ ident: string;
11
+ open(): void;
12
+ stop(): void;
13
+ close(): void;
14
+ }
15
+ export interface ShutterInterfaceWithState extends ShutterInterface {
16
+ getState(): ShutterState;
17
+ onStateChange(cb: (state: ShutterState) => void): () => void;
18
+ }
19
+ export interface ShutterInterfaceWithPosition extends ShutterInterface {
20
+ getPosition(): number;
21
+ setPosition(position: number): void;
22
+ onPositionChange(cb: (position: number) => void): () => void;
23
+ }
24
+ export declare function isShutterWithState(shutter: ShutterInterface): shutter is ShutterInterfaceWithState;
25
+ export declare function isShutterWithPosition(shutter: ShutterInterface): shutter is ShutterInterfaceWithPosition;
@@ -0,0 +1,15 @@
1
+ export const shutterPositions = ['open', 'closed', 'in-between', 'unknown'];
2
+ export const shutterActions = ['opening', 'closing', 'stopping'];
3
+ export const shutterStates = [...shutterPositions, ...shutterActions];
4
+ export function isShutterPosition(state) {
5
+ return !!state && shutterPositions.includes(state);
6
+ }
7
+ export function isShutterAction(state) {
8
+ return !!state && shutterActions.includes(state);
9
+ }
10
+ export function isShutterWithState(shutter) {
11
+ return 'getState' in shutter && 'onStateChange' in shutter;
12
+ }
13
+ export function isShutterWithPosition(shutter) {
14
+ return 'getPosition' in shutter && 'setPosition' in shutter && 'onPositionChange' in shutter;
15
+ }
@@ -0,0 +1,14 @@
1
+ import { Gpio } from 'onoff';
2
+ import { ShutterInterface } from './Shutter.js';
3
+ import type { OnDispose } from '../runtime.js';
4
+ export declare class ThreeButtonShutter implements ShutterInterface {
5
+ readonly ident: string;
6
+ private readonly openButton;
7
+ private readonly stopButton;
8
+ private readonly closeButton;
9
+ constructor(ident: string, openButton: Gpio, stopButton: Gpio, closeButton: Gpio);
10
+ open(): void;
11
+ stop(): void;
12
+ close(): void;
13
+ }
14
+ export declare function createThreeButtonShutter(ident: string, up: number, stop: number, down: number, onDispose: OnDispose): ThreeButtonShutter;
@@ -0,0 +1,25 @@
1
+ import { press, mkOutput } from '../Gpio.js';
2
+ export class ThreeButtonShutter {
3
+ ident;
4
+ openButton;
5
+ stopButton;
6
+ closeButton;
7
+ constructor(ident, openButton, stopButton, closeButton) {
8
+ this.ident = ident;
9
+ this.openButton = openButton;
10
+ this.stopButton = stopButton;
11
+ this.closeButton = closeButton;
12
+ }
13
+ open() {
14
+ press(this.openButton);
15
+ }
16
+ stop() {
17
+ press(this.stopButton);
18
+ }
19
+ close() {
20
+ press(this.closeButton);
21
+ }
22
+ }
23
+ export function createThreeButtonShutter(ident, up, stop, down, onDispose) {
24
+ return new ThreeButtonShutter(ident, mkOutput(up, onDispose), mkOutput(stop, onDispose), mkOutput(down, onDispose));
25
+ }
@@ -0,0 +1,46 @@
1
+ import { Gpio } from 'onoff';
2
+ import { ShutterState, ShutterInterfaceWithState, ShutterInterfaceWithPosition, ShutterPosition } from './Shutter.js';
3
+ export interface Persistence {
4
+ lastFullCloseDurations?: number[];
5
+ lastFullOpenDurations?: number[];
6
+ position?: number;
7
+ state?: ShutterPosition;
8
+ }
9
+ interface Store {
10
+ get: () => Persistence;
11
+ set: (value: Partial<Persistence>) => void;
12
+ }
13
+ export declare class VeluxShutter implements ShutterInterfaceWithState, ShutterInterfaceWithPosition {
14
+ readonly ident: string;
15
+ private readonly up;
16
+ private readonly down;
17
+ private readonly input;
18
+ private readonly store;
19
+ private lastActionStartTime;
20
+ private prevPositionState;
21
+ private positioningTimeout;
22
+ private position;
23
+ private positionListeners;
24
+ private prevState;
25
+ private preStopState;
26
+ private state;
27
+ private stateListeners;
28
+ constructor(ident: string, up: Gpio, down: Gpio, input: Gpio, store: Store);
29
+ private notifyStateChange;
30
+ private notifyPositionChange;
31
+ private storeDuration;
32
+ private getAverageDuration;
33
+ private getPositionDelta;
34
+ private setState;
35
+ private clearPositioningTimeout;
36
+ open(): void;
37
+ stop(): void;
38
+ close(): void;
39
+ setPosition(position: number): void;
40
+ getPosition(): number;
41
+ getState(): "open" | "closed" | "in-between" | "unknown" | "opening" | "closing" | "stopping";
42
+ onPositionChange(cb: (position: number) => void): () => void;
43
+ onStateChange(cb: (state: ShutterState) => void): () => void;
44
+ destroy(): void;
45
+ }
46
+ export {};
@@ -0,0 +1,210 @@
1
+ import { Gpio } from 'onoff';
2
+ import { isShutterPosition, isShutterAction, } from './Shutter.js';
3
+ import { press } from '../Gpio.js';
4
+ const lastDurationsToKeep = 10;
5
+ function minMaxPercentage(num) {
6
+ return Math.min(Math.max(num, 0), 100);
7
+ }
8
+ export class VeluxShutter {
9
+ ident;
10
+ up;
11
+ down;
12
+ input;
13
+ store;
14
+ lastActionStartTime = 0;
15
+ prevPositionState = 'unknown';
16
+ positioningTimeout = null;
17
+ position = 42; //unknown initially
18
+ positionListeners = new Set();
19
+ prevState = 'unknown';
20
+ preStopState = 'unknown';
21
+ state = 'unknown';
22
+ stateListeners = new Set();
23
+ constructor(ident, up, down, input, store) {
24
+ this.ident = ident;
25
+ this.up = up;
26
+ this.down = down;
27
+ this.input = input;
28
+ this.store = store;
29
+ const persistence = this.store.get();
30
+ if (typeof persistence.position === 'number' && (persistence.position <= 100 && persistence.position >= 0)) {
31
+ this.position = +persistence.position; //"+" makes sure we have integers only
32
+ }
33
+ if (persistence.state && isShutterPosition(persistence.state)) {
34
+ this.state = persistence.state;
35
+ this.prevPositionState = this.state;
36
+ }
37
+ this.input.watch((err, value) => {
38
+ if (err) {
39
+ console.error('Error watching input', err);
40
+ }
41
+ else if (value === Gpio.LOW) {
42
+ if (this.state === 'opening') {
43
+ this.setState('open');
44
+ }
45
+ else if (this.state === 'closing') {
46
+ this.setState('closed');
47
+ }
48
+ else if (this.state === 'stopping') {
49
+ this.setState('in-between');
50
+ }
51
+ }
52
+ });
53
+ }
54
+ notifyStateChange() {
55
+ this.stateListeners.forEach(listener => {
56
+ try {
57
+ listener(this.state);
58
+ }
59
+ catch (err) {
60
+ console.error('Error in state listener', err);
61
+ }
62
+ });
63
+ }
64
+ notifyPositionChange() {
65
+ this.positionListeners.forEach(listener => {
66
+ try {
67
+ listener(this.position);
68
+ }
69
+ catch (err) {
70
+ console.error('Error in position listener', err);
71
+ }
72
+ });
73
+ }
74
+ storeDuration(key, duration) {
75
+ if (duration > 0) {
76
+ let array = this.store.get()[key] ?? [];
77
+ array.unshift(duration);
78
+ array = array.slice(0, lastDurationsToKeep);
79
+ this.store.set({ [key]: array });
80
+ }
81
+ }
82
+ getAverageDuration(action) {
83
+ let lastDurations = [];
84
+ if (action === 'opening') {
85
+ lastDurations = this.store.get().lastFullOpenDurations ?? [];
86
+ }
87
+ if (action === 'closing') {
88
+ lastDurations = this.store.get().lastFullCloseDurations ?? [];
89
+ }
90
+ if (lastDurations.length) {
91
+ const sum = lastDurations.reduce((a, b) => a + b, 0);
92
+ return sum / lastDurations.length;
93
+ }
94
+ return 0;
95
+ }
96
+ getPositionDelta(prevState, duration) {
97
+ if (isShutterAction(prevState) && duration > 0) {
98
+ const avg = this.getAverageDuration(prevState);
99
+ if (avg > 0) {
100
+ return minMaxPercentage(Math.round(duration / avg * 100));
101
+ }
102
+ }
103
+ return 0;
104
+ }
105
+ setState(state) {
106
+ this.prevState = this.state;
107
+ this.state = state;
108
+ this.notifyStateChange();
109
+ if (state === 'stopping') {
110
+ this.preStopState = this.prevState;
111
+ }
112
+ if (state === 'opening' || state === 'closing') {
113
+ this.lastActionStartTime = Date.now();
114
+ //If we apply another action while already running one, we can no longer determine the full duration:
115
+ if (isShutterAction(this.prevState)) {
116
+ this.lastActionStartTime = 0;
117
+ }
118
+ }
119
+ if (isShutterPosition(state)) {
120
+ const duration = this.lastActionStartTime ? (Date.now() - this.lastActionStartTime) : 0;
121
+ if (state === 'closed') {
122
+ this.position = 0;
123
+ }
124
+ else if (state === 'open') {
125
+ this.position = 100;
126
+ }
127
+ else if (state === 'in-between') {
128
+ const prevPosition = this.position;
129
+ const delta = this.getPositionDelta(this.preStopState, duration);
130
+ if (this.preStopState === 'opening') {
131
+ this.position = minMaxPercentage(this.position + delta);
132
+ }
133
+ else if (this.preStopState === 'closing') {
134
+ this.position = minMaxPercentage(this.position - delta);
135
+ }
136
+ }
137
+ this.notifyPositionChange();
138
+ this.store.set({ state, position: this.position });
139
+ let prevPositionState = this.prevPositionState;
140
+ this.prevPositionState = state;
141
+ if (prevPositionState === 'open' && state === 'closed') {
142
+ this.storeDuration('lastFullCloseDurations', duration);
143
+ }
144
+ if (prevPositionState === 'closed' && state === 'open') {
145
+ this.storeDuration('lastFullOpenDurations', duration);
146
+ }
147
+ }
148
+ }
149
+ clearPositioningTimeout() {
150
+ if (this.positioningTimeout) {
151
+ clearTimeout(this.positioningTimeout);
152
+ this.positioningTimeout = null;
153
+ }
154
+ }
155
+ open() {
156
+ this.clearPositioningTimeout();
157
+ this.setState('opening');
158
+ press(this.up);
159
+ }
160
+ stop() {
161
+ this.clearPositioningTimeout();
162
+ this.setState('stopping');
163
+ press(this.up, this.down);
164
+ }
165
+ close() {
166
+ this.clearPositioningTimeout();
167
+ this.setState('closing');
168
+ press(this.down);
169
+ }
170
+ setPosition(position) {
171
+ this.clearPositioningTimeout();
172
+ if (position === 0) {
173
+ this.close();
174
+ }
175
+ else if (position === 100) {
176
+ this.open();
177
+ }
178
+ else if (position > 0 && position < 100) {
179
+ let timeout = 0;
180
+ if (position > this.position) {
181
+ timeout = this.getAverageDuration('opening') * (position - this.position) / 100;
182
+ this.open();
183
+ }
184
+ else {
185
+ timeout = this.getAverageDuration('closing') * (this.position - position) / 100;
186
+ this.close();
187
+ }
188
+ if (timeout) {
189
+ this.positioningTimeout = setTimeout(() => this.stop(), timeout);
190
+ }
191
+ }
192
+ }
193
+ getPosition() {
194
+ return this.position;
195
+ }
196
+ getState() {
197
+ return this.state;
198
+ }
199
+ onPositionChange(cb) {
200
+ this.positionListeners.add(cb);
201
+ return () => this.positionListeners.delete(cb);
202
+ }
203
+ onStateChange(cb) {
204
+ this.stateListeners.add(cb);
205
+ return () => this.stateListeners.delete(cb);
206
+ }
207
+ destroy() {
208
+ this.clearPositioningTimeout();
209
+ }
210
+ }
@@ -0,0 +1,9 @@
1
+ import type { OnDispose } from '../runtime.js';
2
+ import { VeluxShutter } from './VeluxShutter.js';
3
+ export interface VeluxConfig {
4
+ ident: string;
5
+ up: number;
6
+ down: number;
7
+ input: number;
8
+ }
9
+ export declare function createVeluxShutters(shutters: readonly VeluxConfig[], onDispose: OnDispose): readonly VeluxShutter[];
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'fs';
2
+ import { existsSync, writeFile } from 'node:fs';
3
+ import { VeluxShutter } from './VeluxShutter.js';
4
+ import { mkInput, mkOutput } from '../Gpio.js';
5
+ import debounce from 'lodash.debounce';
6
+ export function createVeluxShutters(shutters, onDispose) {
7
+ const persistenceFile = '/tmp/velux-shutter-state.json';
8
+ let storage = {};
9
+ if (existsSync(persistenceFile)) {
10
+ const file = readFileSync(persistenceFile).toString();
11
+ try {
12
+ storage = JSON.parse(file);
13
+ }
14
+ catch (e) {
15
+ console.error('Error parsing persistence file', e);
16
+ }
17
+ }
18
+ const save = debounce(() => {
19
+ writeFile(persistenceFile, JSON.stringify(storage), (err) => {
20
+ if (err) {
21
+ console.error('Error writing persistence file', err);
22
+ }
23
+ });
24
+ }, 200);
25
+ return shutters.map((cfg) => {
26
+ const { ident, up, down, input } = cfg;
27
+ const shutter = new VeluxShutter(ident, mkOutput(up, onDispose), mkOutput(down, onDispose), mkInput(input, onDispose), {
28
+ get: () => storage[ident] ?? {},
29
+ set: (obj) => {
30
+ storage[ident] = { ...storage[ident], ...obj };
31
+ save();
32
+ },
33
+ });
34
+ onDispose(() => shutter.destroy());
35
+ return shutter;
36
+ });
37
+ }
@@ -0,0 +1,5 @@
1
+ export { createVeluxShutters, type VeluxConfig } from './Shutter/VeluxShutterFactory.js';
2
+ export { initRuntime, type OnDispose } from './runtime.js';
3
+ export { initMqtt } from './mqtt/mqtt.js';
4
+ export { createThreeButtonShutter } from './Shutter/ThreeButtonShutter.js';
5
+ export * from './Shutter/Shutter.js';
@@ -0,0 +1,5 @@
1
+ export { createVeluxShutters } from './Shutter/VeluxShutterFactory.js';
2
+ export { initRuntime } from './runtime.js';
3
+ export { initMqtt } from './mqtt/mqtt.js';
4
+ export { createThreeButtonShutter } from './Shutter/ThreeButtonShutter.js';
5
+ export * from './Shutter/Shutter.js';
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Device information for MQTT discovery
3
+ */
4
+ export interface MqttDevice {
5
+ /** List of identifiers for the device */
6
+ identifiers: string | string[];
7
+ /** Name of the device */
8
+ name?: string;
9
+ /** Manufacturer of the device */
10
+ manufacturer?: string;
11
+ /** Model of the device */
12
+ model?: string;
13
+ /** Serial number of the device */
14
+ serial_number?: string;
15
+ /** Software version of the device */
16
+ sw_version?: string;
17
+ /** Hardware version of the device */
18
+ hw_version?: string;
19
+ /** Configuration URL for the device */
20
+ configuration_url?: string;
21
+ /** Connections of the device (e.g., [["mac", "02:5b:26:a8:dc:12"]]) */
22
+ connections?: [string, string][];
23
+ /** Suggested area for the device */
24
+ suggested_area?: string;
25
+ /** URL to a webpage that can manage the device */
26
+ via_device?: string;
27
+ }
28
+ /**
29
+ * Origin information for MQTT discovery
30
+ */
31
+ export interface MqttOrigin {
32
+ /** Name of the origin */
33
+ name: string;
34
+ /** Software version */
35
+ sw_version?: string;
36
+ /** Support URL */
37
+ support_url?: string;
38
+ }
39
+ /**
40
+ * Availability configuration for MQTT entities
41
+ */
42
+ export interface MqttAvailability {
43
+ /** MQTT topic subscribed to receive availability (online/offline) updates */
44
+ topic: string;
45
+ /** Payload that represents the available state */
46
+ payload_available?: string;
47
+ /** Payload that represents the unavailable state */
48
+ payload_not_available?: string;
49
+ /** Template to extract value from availability topic */
50
+ value_template?: string;
51
+ }
52
+ /**
53
+ * MQTT Cover component configuration for auto-discovery
54
+ * @see https://www.home-assistant.io/integrations/cover.mqtt/
55
+ */
56
+ export interface MqttCoverConfig {
57
+ platform: 'cover';
58
+ /** Name of the cover entity */
59
+ name?: string;
60
+ /** Unique ID for the entity (required for discovery) */
61
+ unique_id: string;
62
+ /** Device information */
63
+ device?: MqttDevice;
64
+ /** Origin information */
65
+ origin?: MqttOrigin;
66
+ /** Single availability topic or array of availability configurations */
67
+ availability?: MqttAvailability | MqttAvailability[];
68
+ /** MQTT topic subscribed to receive availability (online/offline) updates */
69
+ availability_topic?: string;
70
+ /** Payload that represents the available state */
71
+ payload_available?: string;
72
+ /** Payload that represents the unavailable state */
73
+ payload_not_available?: string;
74
+ /** Availability mode (all, any, latest) */
75
+ availability_mode?: 'all' | 'any' | 'latest';
76
+ /** Template to extract value from availability topic */
77
+ availability_template?: string;
78
+ /** MQTT topic to publish commands (open/close/stop) */
79
+ command_topic: string;
80
+ /** MQTT topic to set the position */
81
+ set_position_topic?: string;
82
+ /** MQTT topic to set the tilt */
83
+ tilt_command_topic?: string;
84
+ /** MQTT topic subscribed to receive state updates */
85
+ state_topic?: string;
86
+ /** MQTT topic for position updates */
87
+ position_topic?: string;
88
+ /** MQTT topic for tilt updates */
89
+ tilt_status_topic?: string;
90
+ /** Payload to open the cover */
91
+ payload_open?: string;
92
+ /** Payload to close the cover */
93
+ payload_close?: string;
94
+ /** Payload to stop the cover */
95
+ payload_stop?: string;
96
+ /** State value for open state */
97
+ state_open?: string;
98
+ /** State value for closed state */
99
+ state_closed?: string;
100
+ /** State value for opening state */
101
+ state_opening?: string;
102
+ /** State value for closing state */
103
+ state_closing?: string;
104
+ /** State value for stopped state */
105
+ state_stopped?: string;
106
+ /** Minimum position value (default: 0) */
107
+ position_closed?: number;
108
+ /** Maximum position value (default: 100) */
109
+ position_open?: number;
110
+ /** Minimum tilt value (default: 0) */
111
+ tilt_min?: number;
112
+ /** Maximum tilt value (default: 100) */
113
+ tilt_max?: number;
114
+ /** Payload to open the tilt */
115
+ tilt_opened_value?: number;
116
+ /** Payload to close the tilt */
117
+ tilt_closed_value?: number;
118
+ /** Inverts the tilt values */
119
+ tilt_invert_state?: boolean;
120
+ /** Template to extract position from position_topic */
121
+ position_template?: string;
122
+ /** Template to extract state from state_topic */
123
+ value_template?: string;
124
+ /** Template to extract tilt from tilt_status_topic */
125
+ tilt_status_template?: string;
126
+ /** Type of cover (awning, blind, curtain, damper, door, garage, gate, shade, shutter, window) */
127
+ device_class?: 'awning' | 'blind' | 'curtain' | 'damper' | 'door' | 'garage' | 'gate' | 'shade' | 'shutter' | 'window';
128
+ /** Flag that defines if cover works in optimistic mode (default: true if no state_topic) */
129
+ optimistic?: boolean;
130
+ /** QoS level for commands */
131
+ qos?: 0 | 1 | 2;
132
+ /** If the published message should have the retain flag set */
133
+ retain?: boolean;
134
+ /** Defines the encoding of the payloads received and published messages */
135
+ encoding?: string;
136
+ /** Icon for the entity */
137
+ icon?: string;
138
+ /** Category of the entity */
139
+ entity_category?: 'config' | 'diagnostic';
140
+ /** Flag which defines if the entity should be enabled when first added */
141
+ enabled_by_default?: boolean;
142
+ /** Object ID for entity registry */
143
+ object_id?: string;
144
+ }
145
+ /**
146
+ * Complete MQTT Device Auto-Discovery Payload
147
+ * Used for homeassistant/device/<device_id>/config topic
148
+ */
149
+ export interface MqttDeviceDiscoveryPayload {
150
+ /** Device information */
151
+ device: MqttDevice;
152
+ /** Origin information */
153
+ origin?: MqttOrigin;
154
+ /** Availability topic for the device */
155
+ availability_topic?: string;
156
+ /** Payload that represents the available state */
157
+ payload_available?: string;
158
+ /** Payload that represents the unavailable state */
159
+ payload_not_available?: string;
160
+ /** Components (entities) associated with this device */
161
+ components: Record<string, MqttCoverConfig>;
162
+ }
@@ -0,0 +1,2 @@
1
+ // Generated with the help of AI based on https://www.home-assistant.io/integrations/mqtt/
2
+ export {};
@@ -0,0 +1,5 @@
1
+ import { type IClientOptions } from 'mqtt';
2
+ import { ShutterInterface } from '../Shutter/Shutter.js';
3
+ export declare function initMqtt(shutters: readonly ShutterInterface[], { url, ...mqttOpts }: {
4
+ url: string;
5
+ } & IClientOptions, namespace?: string): () => Promise<void>;
@@ -0,0 +1,134 @@
1
+ import mqtt from 'mqtt';
2
+ import { isShutterWithState, isShutterWithPosition } from '../Shutter/Shutter.js';
3
+ // @see https://www.home-assistant.io/integrations/mqtt
4
+ // @see https://www.home-assistant.io/integrations/cover.mqtt/
5
+ // @see https://www.home-assistant.io/integrations/cover/
6
+ const ns0 = 'uncaught-gpio-shutter-bridge';
7
+ function validateNamespacePart(str) {
8
+ if (!/[a-zA-Z][a-zA-Z0-9_-]*/.test(str)) {
9
+ throw new Error(`Invalid string for namespace: ${str}`);
10
+ }
11
+ }
12
+ export function initMqtt(shutters, { url, ...mqttOpts }, namespace = 'shutter') {
13
+ const shuttersById = new Map(shutters.map((s) => [s.ident, s]));
14
+ validateNamespacePart(namespace);
15
+ const ns1 = namespace;
16
+ const fullNs = `${ns0}/${ns1}`;
17
+ const deviceId = `${ns0}-${ns1}`;
18
+ const deviceAvailabilityTopic = `${fullNs}/-/availability`;
19
+ const client = mqtt.connect(url, { ...mqttOpts, clientId: fullNs });
20
+ const publish = (topic, message, opts) => {
21
+ console.log('MQTT publish', topic, message, opts);
22
+ client.publish(topic, message, opts);
23
+ };
24
+ client.on('error', (err) => {
25
+ console.error(err);
26
+ });
27
+ client.on('message', (topic, payloadBuffer) => {
28
+ const parts = topic.split('/');
29
+ const payload = payloadBuffer.toString();
30
+ console.log('MQTT message', topic, payload);
31
+ if (parts[0] === ns0 && parts[1] === ns1) {
32
+ const ident = parts[2];
33
+ const shutter = shuttersById.get(ident ?? '');
34
+ if (!shutter) {
35
+ return;
36
+ }
37
+ const subTopic = parts[3];
38
+ if (subTopic === 'set') {
39
+ if (payload === 'open') {
40
+ shutter.open();
41
+ }
42
+ else if (payload === 'close') {
43
+ shutter.close();
44
+ }
45
+ else if (payload === 'stop') {
46
+ shutter.stop();
47
+ }
48
+ }
49
+ else if (subTopic === 'set-position' && isShutterWithPosition(shutter)) {
50
+ shutter.setPosition(parseInt(payload));
51
+ }
52
+ }
53
+ });
54
+ client.on('connect', () => {
55
+ console.log('MQTT connected');
56
+ client.subscribe(`${fullNs}/+/set`);
57
+ client.subscribe(`${fullNs}/+/set-position`);
58
+ const autoDiscoveryPayload = {
59
+ availability_topic: deviceAvailabilityTopic,
60
+ components: {},
61
+ device: {
62
+ identifiers: deviceId,
63
+ name: 'GPIO Shutter Bridge',
64
+ manufacturer: 'uncaught',
65
+ model: 'GPIO Shutter',
66
+ },
67
+ origin: {
68
+ name: ns0,
69
+ sw_version: '1.0.0',
70
+ support_url: 'https://github.com/uncaught/gpio-shutter-bridge',
71
+ },
72
+ payload_available: 'online',
73
+ payload_not_available: 'offline',
74
+ };
75
+ for (const shutter of shutters) {
76
+ validateNamespacePart(shutter.ident);
77
+ const shutterNs = `${fullNs}/${shutter.ident}`;
78
+ //Auto-discovery:
79
+ const objectId = `${deviceId}-${shutter.ident}`;
80
+ const autoDiscoveryComponent = {
81
+ command_topic: `${shutterNs}/set`,
82
+ device_class: 'shutter', //see https://www.home-assistant.io/integrations/cover/#device-class
83
+ name: `Shutter ${shutter.ident.replaceAll(/_/g, ' ')}`,
84
+ optimistic: true,
85
+ payload_close: 'close',
86
+ payload_open: 'open',
87
+ payload_stop: 'stop',
88
+ platform: 'cover',
89
+ unique_id: objectId,
90
+ };
91
+ autoDiscoveryPayload.components[objectId] = autoDiscoveryComponent;
92
+ if (isShutterWithState(shutter)) {
93
+ const publishState = (state) => {
94
+ if (state !== 'stopping' && state !== 'unknown') {
95
+ publish(`${shutterNs}/state`, {
96
+ 'closed': 'closed',
97
+ 'closing': 'closing',
98
+ 'in-between': 'stopped',
99
+ 'open': 'open',
100
+ 'opening': 'opening',
101
+ }[state], { retain: true });
102
+ }
103
+ };
104
+ publishState(shutter.getState());
105
+ shutter.onStateChange(publishState);
106
+ autoDiscoveryComponent.optimistic = false;
107
+ autoDiscoveryComponent.state_closed = 'closed';
108
+ autoDiscoveryComponent.state_closing = 'closing';
109
+ autoDiscoveryComponent.state_open = 'open';
110
+ autoDiscoveryComponent.state_opening = 'opening';
111
+ autoDiscoveryComponent.state_stopped = 'stopped';
112
+ autoDiscoveryComponent.state_topic = `${shutterNs}/state`;
113
+ }
114
+ if (isShutterWithPosition(shutter)) {
115
+ const publishPosition = (position) => {
116
+ publish(`${shutterNs}/position`, position.toString(), { retain: true });
117
+ };
118
+ publishPosition(shutter.getPosition());
119
+ shutter.onPositionChange(publishPosition);
120
+ autoDiscoveryComponent.optimistic = false;
121
+ autoDiscoveryComponent.position_open = 100;
122
+ autoDiscoveryComponent.position_closed = 0;
123
+ autoDiscoveryComponent.position_topic = `${shutterNs}/position`;
124
+ autoDiscoveryComponent.set_position_topic = `${shutterNs}/set-position`;
125
+ }
126
+ }
127
+ publish(`homeassistant/device/${deviceId}/config`, JSON.stringify(autoDiscoveryPayload), { retain: true });
128
+ publish(deviceAvailabilityTopic, 'online', { retain: true });
129
+ });
130
+ return async () => {
131
+ publish(deviceAvailabilityTopic, 'offline', { retain: true });
132
+ await client.endAsync();
133
+ };
134
+ }
@@ -0,0 +1,5 @@
1
+ export type OnDispose = (disposable: () => void | Promise<void>) => () => void;
2
+ export declare function initRuntime(): {
3
+ exit: (err?: Error | unknown) => Promise<void>;
4
+ onDispose: OnDispose;
5
+ };
@@ -0,0 +1,29 @@
1
+ export function initRuntime() {
2
+ const disposables = new Set();
3
+ const onDispose = (cb) => {
4
+ disposables.add(cb);
5
+ return () => disposables.delete(cb);
6
+ };
7
+ async function exit(err) {
8
+ for (const disposable of disposables) {
9
+ try {
10
+ await disposable();
11
+ }
12
+ catch (e) {
13
+ console.error('Error in disposable', e);
14
+ }
15
+ }
16
+ if (err) {
17
+ console.error(err);
18
+ process.exit(1);
19
+ }
20
+ else {
21
+ process.exit(0);
22
+ }
23
+ }
24
+ process.on('SIGINT', () => exit());
25
+ process.on('SIGTERM', () => exit());
26
+ process.on('uncaughtException', (err) => exit(err));
27
+ process.on('unhandledRejection', (err) => exit(err));
28
+ return { exit, onDispose };
29
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@uncaught/gpio-shutter-bridge",
3
+ "author": "uncaught <uncaught42@gmail.com>",
4
+ "license": "MIT",
5
+ "version": "1.0.0",
6
+ "description": "MQTT shutter bridge for home assistant with Velux KLF 150 support",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/uncaught/gpio-shutter-bridge.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/uncaught/gpio-shutter-bridge/issues"
13
+ },
14
+ "homepage": "https://github.com/uncaught/gpio-shutter-bridge#readme",
15
+ "type": "module",
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "dependencies": {
23
+ "lodash.debounce": "^4.0.8",
24
+ "mqtt": "^5.14.1",
25
+ "onoff": "^6.0.3"
26
+ },
27
+ "devDependencies": {
28
+ "@tsconfig/node20": "^20.1.6",
29
+ "@types/lodash.debounce": "^4.0.9",
30
+ "@types/node": "^20",
31
+ "typescript": "^5.9.3"
32
+ },
33
+ "sideEffects": [
34
+ "./example.ts"
35
+ ],
36
+ "keywords": [
37
+ "mqtt",
38
+ "home-assistant",
39
+ "velux",
40
+ "klf150",
41
+ "shutter",
42
+ "gpio",
43
+ "raspberry-pi"
44
+ ],
45
+ "files": [
46
+ "dist",
47
+ "README.md"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }