@uncaught/gpio-shutter-bridge 1.1.1 → 1.2.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 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 getAverageActionDuration;
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,34 @@ 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
+ getAverageActionDuration(action) {
84
92
  if (action === 'opening') {
85
- lastDurations = this.store.get().lastFullOpenDurations ?? [];
93
+ return this.getAverageDuration('topFullOpenDurations');
86
94
  }
87
95
  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;
96
+ return this.getAverageDuration('topFullCloseDurations');
93
97
  }
94
98
  return 0;
95
99
  }
96
100
  getPositionDelta(prevState, duration) {
97
101
  if (isShutterAction(prevState) && duration > 0) {
98
- const avg = this.getAverageDuration(prevState);
102
+ const avg = this.getAverageActionDuration(prevState);
99
103
  if (avg > 0) {
100
- return minMaxPercentage(Math.round(duration / avg * 100));
104
+ return minMaxPercentage(Math.round((duration / avg) * 100));
101
105
  }
102
106
  }
103
107
  return 0;
@@ -108,6 +112,16 @@ export class VeluxShutter {
108
112
  this.notifyStateChange();
109
113
  if (state === 'stopping') {
110
114
  this.preStopState = this.prevState;
115
+ this.lastStoppingStartTime = Date.now();
116
+ }
117
+ else if (state === 'in-between') {
118
+ //Measure the time it takes between a manual "stop" and the following stopped signal.
119
+ // This gives us an estimate on how long the KLF 150 takes to send it for any operation.
120
+ const stopDuration = this.lastStoppingStartTime ? Date.now() - this.lastStoppingStartTime : 0;
121
+ this.storeDuration('topSignalDurations', stopDuration);
122
+ }
123
+ else {
124
+ this.lastStoppingStartTime = 0;
111
125
  }
112
126
  if (state === 'opening' || state === 'closing') {
113
127
  this.lastActionStartTime = Date.now();
@@ -117,16 +131,29 @@ export class VeluxShutter {
117
131
  }
118
132
  }
119
133
  if (isShutterPosition(state)) {
120
- const duration = this.lastActionStartTime ? (Date.now() - this.lastActionStartTime) : 0;
134
+ const signalDuration = this.getAverageDuration('topSignalDurations');
135
+ const measuredDuration = this.lastActionStartTime ? Date.now() - this.lastActionStartTime : 0;
136
+ const positionDuration = measuredDuration > 0 ? Math.max(0, measuredDuration - signalDuration) : 0;
121
137
  if (state === 'closed') {
122
- this.position = 0;
138
+ const delta = this.getPositionDelta(this.prevState, positionDuration);
139
+ if (delta) {
140
+ this.position = minMaxPercentage(this.position - delta);
141
+ }
142
+ else {
143
+ this.position = 0;
144
+ }
123
145
  }
124
146
  else if (state === 'open') {
125
- this.position = 100;
147
+ const delta = this.getPositionDelta(this.prevState, positionDuration);
148
+ if (delta) {
149
+ this.position = minMaxPercentage(this.position + delta);
150
+ }
151
+ else {
152
+ this.position = 100;
153
+ }
126
154
  }
127
155
  else if (state === 'in-between') {
128
- const prevPosition = this.position;
129
- const delta = this.getPositionDelta(this.preStopState, duration);
156
+ const delta = this.getPositionDelta(this.preStopState, positionDuration);
130
157
  if (this.preStopState === 'opening') {
131
158
  this.position = minMaxPercentage(this.position + delta);
132
159
  }
@@ -139,10 +166,10 @@ export class VeluxShutter {
139
166
  let prevPositionState = this.prevPositionState;
140
167
  this.prevPositionState = state;
141
168
  if (prevPositionState === 'open' && state === 'closed') {
142
- this.storeDuration('lastFullCloseDurations', duration);
169
+ this.storeDuration('topFullCloseDurations', measuredDuration);
143
170
  }
144
171
  if (prevPositionState === 'closed' && state === 'open') {
145
- this.storeDuration('lastFullOpenDurations', duration);
172
+ this.storeDuration('topFullOpenDurations', measuredDuration);
146
173
  }
147
174
  }
148
175
  }
@@ -178,11 +205,11 @@ export class VeluxShutter {
178
205
  else if (position > 0 && position < 100) {
179
206
  let timeout = 0;
180
207
  if (position > this.position) {
181
- timeout = this.getAverageDuration('opening') * (position - this.position) / 100;
208
+ timeout = (this.getAverageActionDuration('opening') * (position - this.position)) / 100;
182
209
  this.open();
183
210
  }
184
211
  else {
185
- timeout = this.getAverageDuration('closing') * (this.position - position) / 100;
212
+ timeout = (this.getAverageActionDuration('closing') * (this.position - position)) / 100;
186
213
  this.close();
187
214
  }
188
215
  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.0",
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
  }