@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 +46 -14
- package/dist/Gpio.js +3 -3
- package/dist/Shutter/Shutter.d.ts +3 -3
- package/dist/Shutter/VeluxShutter.d.ts +8 -3
- package/dist/Shutter/VeluxShutter.js +54 -27
- package/dist/Shutter/VeluxShutterFactory.d.ts +4 -1
- package/dist/Shutter/VeluxShutterFactory.js +1 -2
- package/dist/mqtt/mqtt.js +4 -4
- package/package.json +8 -2
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
|
-
-
|
|
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.
|
|
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.
|
|
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(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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' &&
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
83
|
-
|
|
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
|
-
|
|
93
|
+
return this.getAverageDuration('topFullOpenDurations');
|
|
86
94
|
}
|
|
87
95
|
if (action === 'closing') {
|
|
88
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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('
|
|
169
|
+
this.storeDuration('topFullCloseDurations', measuredDuration);
|
|
143
170
|
}
|
|
144
171
|
if (prevPositionState === 'closed' && state === 'open') {
|
|
145
|
-
this.storeDuration('
|
|
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.
|
|
208
|
+
timeout = (this.getAverageActionDuration('opening') * (position - this.position)) / 100;
|
|
182
209
|
this.open();
|
|
183
210
|
}
|
|
184
211
|
else {
|
|
185
|
-
timeout = this.
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
96
|
+
closed: 'closed',
|
|
97
|
+
closing: 'closing',
|
|
98
98
|
'in-between': 'stopped',
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|