@uncaught/gpio-shutter-bridge 1.1.1 → 1.2.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 CHANGED
@@ -53,7 +53,7 @@ Just to give you an insight, you can pobably use this project with any other har
53
53
  - Before buying this, I also tried soldering contacts to the Velux remotes (model 3UR B01 WW), but I broke the first...
54
54
  - 9 relay modules
55
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)
56
+ - I bought [this 10 pack on amazon](https://www.amazon.de/dp/B0F53QDMXG)
57
57
  - You will need 2 relays per Velux shutter, so for my three windows I needed 6 relays.
58
58
  - Additionally I needed 3 relays for my Schellenberg remote control.
59
59
  - If you find a board with multiple 3V relays on it, go ahead. I just screwed mine together on a wooden board.
@@ -91,39 +91,71 @@ This is for the default configuration as described in the Velux KLF 150 manual.
91
91
  ### Inputs
92
92
 
93
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).
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
95
 
96
96
  # Software
97
97
 
98
- - Make sure you have either `raspi-gpio` or `pinctrl` (newer) installed. Check with `raspi-gpio get` or `pinctrl get`.
98
+ - You will need to have `node` installed.
99
+ - I have to use node 21, but it should work with newer versions as well.
100
+ - Make sure you have either `raspi-gpio` or `pinctrl` (newer) available.
101
+ - Check with `raspi-gpio get` or `pinctrl get`.
99
102
  - Either one is used to set the GPIO input pins to the "pull up" mode. This is not handled in the `onoff`-library I'm using.
100
- - `pinctrl` is newer, but requires permissions. So you either need to be root, or make sure you have access to all `/dev/gpio*` devices:
103
+ - `pinctrl` is newer, but requires permissions.
104
+ - So you either need to be root, or make sure you have access to all `/dev/gpio*` devices.
101
105
  - E.g. use the `gpio` group:
102
106
  - Check `ll /dev/gpio*` and see that every device is owned by the `gpio` group and has `g+rw` permissions.
103
107
  - If not, use `sudo chgrp gpio /dev/gpio*` and/or `sudo chmod g+rw /dev/gpio*`.
104
108
  - You can add yourself to the `gpio` group with `sudo usermod -a -G gpio $USER`.
105
109
  - Verify with `pinctrl get` that you can use the tool without further permissions.
106
- - Go into a folder where you wish to install this project.
110
+ - Go into a folder where you wish to install this project (e.g. `~/shutter`).
107
111
  - Install the library with `npm install @uncaught/gpio-shutter-bridge`
108
- - Create a javascript file (e.g. `run.js`), require my library and call it with your shutter-pin-layout:
112
+ - Create a javascript file (e.g. `run.mjs`), require my library and call it with your shutter-pin-layout:
109
113
 
110
114
  ```js
111
115
  import {createVeluxShutters, initRuntime, initMqtt} from '@uncaught/gpio-shutter-bridge';
112
116
 
113
117
  const {onDispose} = initRuntime();
114
118
 
115
- initMqtt(createVeluxShutters([
116
- {ident: 'Velux_A', up: 2, down: 3, input: 14},
117
- {ident: 'Velux_B', up: 4, down: 17, input: 15},
118
- {ident: 'Velux_C', up: 27, down: 22, input: 18},
119
- {ident: 'Velux_D', up: 10, down: 9, input: 23},
120
- {ident: 'Velux_E', up: 11, down: 8, input: 24}, //same row!
121
- ], onDispose), onDispose, {url: 'mqtt://your-mqtt-or-home-assistant'});
119
+ initMqtt(
120
+ createVeluxShutters(
121
+ [
122
+ {ident: 'Velux_A', up: 2, down: 3, input: 14},
123
+ {ident: 'Velux_B', up: 4, down: 17, input: 15},
124
+ {ident: 'Velux_C', up: 27, down: 22, input: 18},
125
+ {ident: 'Velux_D', up: 10, down: 9, input: 23},
126
+ {ident: 'Velux_E', up: 11, down: 8, input: 24}, //same row!
127
+ ],
128
+ onDispose,
129
+ ),
130
+ onDispose,
131
+ {url: 'mqtt://your-mqtt-or-home-assistant'},
132
+ );
122
133
  ```
123
134
 
124
135
  - The ident should match `/[a-zA-Z][a-zA-Z0-9_-]*/`.
125
136
  - See [my personal example](./example.ts) for a few more details.
126
137
 
138
+ <details>
139
+ <summary>Auto start with screen and crontab</summary>
140
+
141
+ I'm using `screen` to keep the process running and to check in on its output.
142
+
143
+ I created an additional `start.sh` file:
144
+
145
+ ```bash
146
+ #!/usr/bin/env bash
147
+ set -e
148
+ selfDir=$(dirname $(readlink -f $0))
149
+ cd $selfDir
150
+ screen -dmS shutter node run.mjs
151
+ ```
152
+
153
+ Then added `@reboot /home/nc/shutter/start.sh` to my `crontab -e`.
154
+
155
+ If you are unfamiliar with `screen`, you can detach from a session with `ctrl+a+d` and reattach with `screen -r shutter`.
156
+
157
+ </details>
158
+
127
159
  ## Dockerizing
128
160
 
129
161
  I tried to dockerize this project, but I was not able to get it to work with only specific mapped devices. The `pinctrl` kept saying "No GPIO chips found". I've only managed to get it working with the `--privileged` flag, which for me, kind of defeats the purpose of running this inside docker.
@@ -150,4 +182,4 @@ ENTRYPOINT ["bash"]
150
182
 
151
183
  Building with `docker build -t gpio .` and running with `docker run --rm -it --device /dev/gpiochip0 --device /dev/gpiochip1 --device /dev/gpiochip2 --device /dev/gpiomem --cap-add SYS_RAWIO gpio`
152
184
 
153
- </details>
185
+ </details>
package/dist/Gpio.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Gpio } from 'onoff';
2
2
  import { execSync } from 'child_process';
3
3
  async function sleep(wait) {
4
- await new Promise(resolve => setTimeout(resolve, wait));
4
+ await new Promise((resolve) => setTimeout(resolve, wait));
5
5
  }
6
6
  export async function press(...outputs) {
7
- outputs.forEach(output => output.writeSync(1));
7
+ outputs.forEach((output) => output.writeSync(1));
8
8
  await sleep(200);
9
- outputs.forEach(output => output.writeSync(0));
9
+ outputs.forEach((output) => output.writeSync(0));
10
10
  }
11
11
  export function mkOutput(pin, onDispose) {
12
12
  const gpio = new Gpio(pin, 'out');
@@ -1,9 +1,9 @@
1
1
  export declare const shutterPositions: readonly ["open", "closed", "in-between", "unknown"];
2
- export type ShutterPosition = typeof shutterPositions[number];
2
+ export type ShutterPosition = (typeof shutterPositions)[number];
3
3
  export declare const shutterActions: readonly ["opening", "closing", "stopping"];
4
- export type ShutterAction = typeof shutterActions[number];
4
+ export type ShutterAction = (typeof shutterActions)[number];
5
5
  export declare const shutterStates: readonly ["open", "closed", "in-between", "unknown", "opening", "closing", "stopping"];
6
- export type ShutterState = typeof shutterStates[number];
6
+ export type ShutterState = (typeof shutterStates)[number];
7
7
  export declare function isShutterPosition(state: unknown): state is ShutterPosition;
8
8
  export declare function isShutterAction(state: unknown): state is ShutterAction;
9
9
  export interface ShutterInterface {
@@ -1,8 +1,11 @@
1
1
  import { Gpio } from 'onoff';
2
2
  import { ShutterState, ShutterInterfaceWithState, ShutterInterfaceWithPosition, ShutterPosition } from './Shutter.js';
3
- export interface Persistence {
4
- lastFullCloseDurations?: number[];
5
- lastFullOpenDurations?: number[];
3
+ interface Durations {
4
+ topFullCloseDurations?: number[];
5
+ topFullOpenDurations?: number[];
6
+ topSignalDurations?: number[];
7
+ }
8
+ export interface Persistence extends Durations {
6
9
  position?: number;
7
10
  state?: ShutterPosition;
8
11
  }
@@ -17,6 +20,7 @@ export declare class VeluxShutter implements ShutterInterfaceWithState, ShutterI
17
20
  private readonly input;
18
21
  private readonly store;
19
22
  private lastActionStartTime;
23
+ private lastStoppingStartTime;
20
24
  private prevPositionState;
21
25
  private positioningTimeout;
22
26
  private position;
@@ -30,6 +34,7 @@ export declare class VeluxShutter implements ShutterInterfaceWithState, ShutterI
30
34
  private notifyPositionChange;
31
35
  private storeDuration;
32
36
  private getAverageDuration;
37
+ private getEstimatedActionDuration;
33
38
  private getPositionDelta;
34
39
  private setState;
35
40
  private clearPositioningTimeout;
@@ -1,7 +1,7 @@
1
1
  import { Gpio } from 'onoff';
2
2
  import { isShutterPosition, isShutterAction, } from './Shutter.js';
3
3
  import { press } from '../Gpio.js';
4
- const lastDurationsToKeep = 10;
4
+ const durationsToKeep = 20;
5
5
  function minMaxPercentage(num) {
6
6
  return Math.min(Math.max(num, 0), 100);
7
7
  }
@@ -12,6 +12,7 @@ export class VeluxShutter {
12
12
  input;
13
13
  store;
14
14
  lastActionStartTime = 0;
15
+ lastStoppingStartTime = 0;
15
16
  prevPositionState = 'unknown';
16
17
  positioningTimeout = null;
17
18
  position = 42; //unknown initially
@@ -27,7 +28,7 @@ export class VeluxShutter {
27
28
  this.input = input;
28
29
  this.store = store;
29
30
  const persistence = this.store.get();
30
- if (typeof persistence.position === 'number' && (persistence.position <= 100 && persistence.position >= 0)) {
31
+ if (typeof persistence.position === 'number' && persistence.position <= 100 && persistence.position >= 0) {
31
32
  this.position = +persistence.position; //"+" makes sure we have integers only
32
33
  }
33
34
  if (persistence.state && isShutterPosition(persistence.state)) {
@@ -52,7 +53,7 @@ export class VeluxShutter {
52
53
  });
53
54
  }
54
55
  notifyStateChange() {
55
- this.stateListeners.forEach(listener => {
56
+ this.stateListeners.forEach((listener) => {
56
57
  try {
57
58
  listener(this.state);
58
59
  }
@@ -62,7 +63,7 @@ export class VeluxShutter {
62
63
  });
63
64
  }
64
65
  notifyPositionChange() {
65
- this.positionListeners.forEach(listener => {
66
+ this.positionListeners.forEach((listener) => {
66
67
  try {
67
68
  listener(this.position);
68
69
  }
@@ -73,31 +74,36 @@ export class VeluxShutter {
73
74
  }
74
75
  storeDuration(key, duration) {
75
76
  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 });
77
+ //Keep the N longest durations:
78
+ this.store.set({
79
+ [key]: [...(this.store.get()[key] ?? []), duration].sort((a, b) => b - a).slice(0, durationsToKeep),
80
+ });
80
81
  }
81
82
  }
82
- getAverageDuration(action) {
83
- let lastDurations = [];
83
+ getAverageDuration(key) {
84
+ const storedDurations = this.store.get()[key] ?? [];
85
+ if (storedDurations.length) {
86
+ const sum = storedDurations.reduce((a, b) => a + b, 0);
87
+ return sum / storedDurations.length;
88
+ }
89
+ return 0;
90
+ }
91
+ getEstimatedActionDuration(action) {
92
+ const signalDuration = this.getAverageDuration('topSignalDurations');
93
+ let avg = 0;
84
94
  if (action === 'opening') {
85
- lastDurations = this.store.get().lastFullOpenDurations ?? [];
95
+ avg = this.getAverageDuration('topFullOpenDurations');
86
96
  }
87
97
  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;
98
+ avg = this.getAverageDuration('topFullCloseDurations');
93
99
  }
94
- return 0;
100
+ return avg > 0 ? Math.max(0, avg - signalDuration) : 0;
95
101
  }
96
102
  getPositionDelta(prevState, duration) {
97
103
  if (isShutterAction(prevState) && duration > 0) {
98
- const avg = this.getAverageDuration(prevState);
104
+ const avg = this.getEstimatedActionDuration(prevState);
99
105
  if (avg > 0) {
100
- return minMaxPercentage(Math.round(duration / avg * 100));
106
+ return minMaxPercentage(Math.round((duration / avg) * 100));
101
107
  }
102
108
  }
103
109
  return 0;
@@ -108,6 +114,16 @@ export class VeluxShutter {
108
114
  this.notifyStateChange();
109
115
  if (state === 'stopping') {
110
116
  this.preStopState = this.prevState;
117
+ this.lastStoppingStartTime = Date.now();
118
+ }
119
+ else if (state === 'in-between') {
120
+ //Measure the time it takes between a manual "stop" and the following stopped signal.
121
+ // This gives us an estimate on how long the KLF 150 takes to send it for any operation.
122
+ const stopDuration = this.lastStoppingStartTime ? Date.now() - this.lastStoppingStartTime : 0;
123
+ this.storeDuration('topSignalDurations', stopDuration);
124
+ }
125
+ else {
126
+ this.lastStoppingStartTime = 0;
111
127
  }
112
128
  if (state === 'opening' || state === 'closing') {
113
129
  this.lastActionStartTime = Date.now();
@@ -117,16 +133,29 @@ export class VeluxShutter {
117
133
  }
118
134
  }
119
135
  if (isShutterPosition(state)) {
120
- const duration = this.lastActionStartTime ? (Date.now() - this.lastActionStartTime) : 0;
136
+ const signalDuration = this.getAverageDuration('topSignalDurations');
137
+ const measuredDuration = this.lastActionStartTime ? Date.now() - this.lastActionStartTime : 0;
138
+ const positionDuration = measuredDuration > 0 ? Math.max(0, measuredDuration - signalDuration) : 0;
121
139
  if (state === 'closed') {
122
- this.position = 0;
140
+ const delta = this.getPositionDelta(this.prevState, positionDuration);
141
+ if (delta) {
142
+ this.position = minMaxPercentage(this.position - delta);
143
+ }
144
+ else {
145
+ this.position = 0;
146
+ }
123
147
  }
124
148
  else if (state === 'open') {
125
- this.position = 100;
149
+ const delta = this.getPositionDelta(this.prevState, positionDuration);
150
+ if (delta) {
151
+ this.position = minMaxPercentage(this.position + delta);
152
+ }
153
+ else {
154
+ this.position = 100;
155
+ }
126
156
  }
127
157
  else if (state === 'in-between') {
128
- const prevPosition = this.position;
129
- const delta = this.getPositionDelta(this.preStopState, duration);
158
+ const delta = this.getPositionDelta(this.preStopState, positionDuration);
130
159
  if (this.preStopState === 'opening') {
131
160
  this.position = minMaxPercentage(this.position + delta);
132
161
  }
@@ -139,10 +168,10 @@ export class VeluxShutter {
139
168
  let prevPositionState = this.prevPositionState;
140
169
  this.prevPositionState = state;
141
170
  if (prevPositionState === 'open' && state === 'closed') {
142
- this.storeDuration('lastFullCloseDurations', duration);
171
+ this.storeDuration('topFullCloseDurations', measuredDuration);
143
172
  }
144
173
  if (prevPositionState === 'closed' && state === 'open') {
145
- this.storeDuration('lastFullOpenDurations', duration);
174
+ this.storeDuration('topFullOpenDurations', measuredDuration);
146
175
  }
147
176
  }
148
177
  }
@@ -178,11 +207,11 @@ export class VeluxShutter {
178
207
  else if (position > 0 && position < 100) {
179
208
  let timeout = 0;
180
209
  if (position > this.position) {
181
- timeout = this.getAverageDuration('opening') * (position - this.position) / 100;
210
+ timeout = (this.getEstimatedActionDuration('opening') * (position - this.position)) / 100;
182
211
  this.open();
183
212
  }
184
213
  else {
185
- timeout = this.getAverageDuration('closing') * (this.position - position) / 100;
214
+ timeout = (this.getEstimatedActionDuration('closing') * (this.position - position)) / 100;
186
215
  this.close();
187
216
  }
188
217
  if (timeout) {
@@ -6,4 +6,7 @@ export interface VeluxConfig {
6
6
  down: number;
7
7
  input: number;
8
8
  }
9
- export declare function createVeluxShutters(shutters: readonly VeluxConfig[], onDispose: OnDispose): readonly VeluxShutter[];
9
+ export interface VeluxOptions {
10
+ persistenceFile?: string;
11
+ }
12
+ export declare function createVeluxShutters(shutters: readonly VeluxConfig[], onDispose: OnDispose, { persistenceFile }?: VeluxOptions): readonly VeluxShutter[];
@@ -3,8 +3,7 @@ import { existsSync, writeFile } from 'node:fs';
3
3
  import { VeluxShutter } from './VeluxShutter.js';
4
4
  import { mkInput, mkOutput } from '../Gpio.js';
5
5
  import debounce from 'lodash.debounce';
6
- export function createVeluxShutters(shutters, onDispose) {
7
- const persistenceFile = '/tmp/velux-shutter-state.json';
6
+ export function createVeluxShutters(shutters, onDispose, { persistenceFile = '/tmp/velux-shutter-state.json' } = {}) {
8
7
  let storage = {};
9
8
  if (existsSync(persistenceFile)) {
10
9
  const file = readFileSync(persistenceFile).toString();
package/dist/mqtt/mqtt.js CHANGED
@@ -93,11 +93,11 @@ export function initMqtt(shutters, onDispose, { url, ...mqttOpts }, namespace =
93
93
  const publishState = (state) => {
94
94
  if (state !== 'stopping' && state !== 'unknown') {
95
95
  publish(`${shutterNs}/state`, {
96
- 'closed': 'closed',
97
- 'closing': 'closing',
96
+ closed: 'closed',
97
+ closing: 'closing',
98
98
  'in-between': 'stopped',
99
- 'open': 'open',
100
- 'opening': 'opening',
99
+ open: 'open',
100
+ opening: 'opening',
101
101
  }[state], { retain: true });
102
102
  }
103
103
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@uncaught/gpio-shutter-bridge",
3
3
  "author": "uncaught <uncaught42@gmail.com>",
4
4
  "license": "MIT",
5
- "version": "1.1.1",
5
+ "version": "1.2.1",
6
6
  "description": "MQTT shutter bridge for home assistant with Velux KLF 150 support",
7
7
  "repository": {
8
8
  "type": "git",
@@ -17,7 +17,7 @@
17
17
  "types": "./dist/index.d.ts",
18
18
  "scripts": {
19
19
  "build": "tsc",
20
- "prepublishOnly": "npm run build"
20
+ "prettier": "prettier --write ."
21
21
  },
22
22
  "dependencies": {
23
23
  "lodash.debounce": "^4.0.8",
@@ -28,6 +28,7 @@
28
28
  "@tsconfig/node20": "^20.1.6",
29
29
  "@types/lodash.debounce": "^4.0.9",
30
30
  "@types/node": "^20",
31
+ "prettier": "^3.8.0",
31
32
  "typescript": "^5.9.3"
32
33
  },
33
34
  "sideEffects": [
@@ -46,6 +47,11 @@
46
47
  "dist",
47
48
  "README.md"
48
49
  ],
50
+ "prettier": {
51
+ "bracketSpacing": false,
52
+ "printWidth": 120,
53
+ "singleQuote": true
54
+ },
49
55
  "publishConfig": {
50
56
  "access": "public"
51
57
  }