From aab3397bf9bdcce09b6f85648bf2f13d56c6afec Mon Sep 17 00:00:00 2001 From: Kohei Watanabe <kou029w@gmail.com> Date: Sun, 13 Oct 2019 02:13:19 +0900 Subject: [PATCH] create @notweb/gpio --- .gitignore | 1 + LICENSE | 21 +++++ README.md | 27 +++++++ index.d.ts | 57 ++++++++++++++ index.js | 142 +++++++++++++++++++++++++++++++++ index.ts | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 22 ++++++ tsconfig.json | 12 +++ yarn.lock | 13 +++ 9 files changed, 508 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.d.ts create mode 100644 index.js create mode 100644 index.ts create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ccbe46 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5376ef9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Kohei Watanabe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f734a1 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# @notweb/gpio + +GPIO access with Node.js + +## Usage + +```js +const { requestGPIOAccess } = require("@notweb/gpio"); + +async function main() { + const gpioAccess = await requestGPIOAccess(); + const port = gpioAccess.ports.get(26); + + await port.export("out"); + + for (;;) { + port.write(value); + await new Promise(resolve => setTimeout(resolve, 1e3)); + } +} + +main(); +``` + +## Document + +[Web GPIO API](http://browserobo.github.io/WebGPIO) diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..4a82599 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,57 @@ +/// <reference types="node" /> +import { EventEmitter } from "events"; +declare type PortNumber = number; +declare type PortName = string; +declare type PinName = string; +declare type DirectionMode = "in" | "out"; +declare type GPIOValue = 0 | 1; +interface GPIOChangeEvent { + readonly value: GPIOValue; + readonly port: GPIOPort; +} +interface GPIOChangeEventHandler { + (event: GPIOChangeEvent): void; +} +/** + * Not a specification in Web GPIO API. + */ +interface GPIOPortChangeEventHandler { + (event: GPIOChangeEvent["value"]): void; +} +export declare class GPIOAccess extends EventEmitter { + private readonly _ports; + onchange: GPIOChangeEventHandler | undefined; + constructor(ports?: GPIOPortMap); + readonly ports: GPIOPortMap; + /** + * Unexport all exported GPIO ports. + */ + unexportAll(): Promise<void>; +} +export declare class GPIOPortMap extends Map<PortNumber, GPIOPort> { +} +export declare class GPIOPort extends EventEmitter { + private readonly _portNumber; + private readonly _pollingInterval; + private _direction; + private _exported; + private _value; + private _timeout; + onchange: GPIOPortChangeEventHandler | undefined; + constructor(portNumber: PortNumber); + readonly portNumber: PortNumber; + readonly portName: PortName; + readonly pinName: PinName; + readonly direction: DirectionMode; + readonly exported: boolean; + export(direction: DirectionMode): Promise<void>; + unexport(): Promise<void>; + read(): Promise<GPIOValue>; + write(value: GPIOValue): Promise<void>; +} +export declare class InvalidAccessError extends Error { +} +export declare class OperationError extends Error { +} +export declare function requestGPIOAccess(): Promise<GPIOAccess>; +export {}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..a5a6f63 --- /dev/null +++ b/index.js @@ -0,0 +1,142 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const events_1 = require("events"); +const fs_1 = require("fs"); +/** + * Interval of file system polling, in milliseconds. + */ +const PollingInterval = 100; +const Uint16Max = 65535; +function parseUint16(string) { + const n = Number.parseInt(string, 10); + if (0 <= n && n <= Uint16Max) + return n; + else + throw new RangeError(`Must be between 0 and ${Uint16Max}.`); +} +class GPIOAccess extends events_1.EventEmitter { + constructor(ports) { + super(); + this._ports = ports == null ? new GPIOPortMap() : ports; + this._ports.forEach(port => port.on("change", value => { + const event = { value, port }; + this.emit("change", event); + })); + this.on("change", (event) => { + if (this.onchange !== undefined) + this.onchange(event); + }); + } + get ports() { + return this._ports; + } + /** + * Unexport all exported GPIO ports. + */ + async unexportAll() { + await Promise.all([...this.ports.values()].map(port => port.exported ? port.unexport() : undefined)); + } +} +exports.GPIOAccess = GPIOAccess; +class GPIOPortMap extends Map { +} +exports.GPIOPortMap = GPIOPortMap; +class GPIOPort extends events_1.EventEmitter { + constructor(portNumber) { + super(); + this._portNumber = portNumber; + this._pollingInterval = PollingInterval; + this._direction = new OperationError("Unknown direction."); + this._exported = new OperationError("Unknown export."); + this.on("change", (value) => { + if (this.onchange !== undefined) + this.onchange(value); + }); + } + get portNumber() { + return this._portNumber; + } + get portName() { + // NOTE: Unknown portName. + return ""; + } + get pinName() { + // NOTE: Unknown pinName. + return ""; + } + get direction() { + if (this._direction instanceof OperationError) + throw this._direction; + return this._direction; + } + get exported() { + if (this._exported instanceof OperationError) + throw this._exported; + return this._exported; + } + async export(direction) { + if (!/^(in|out)$/.test(direction)) { + throw new InvalidAccessError(`Must be "in" or "out".`); + } + try { + clearInterval(this._timeout); + await fs_1.promises.writeFile(`/sys/class/gpio/export`, parseUint16(this.portNumber.toString()).toString()); + await fs_1.promises.writeFile(`/sys/class/gpio/${parseUint16(this.portNumber.toString())}/direction`, direction); + if (direction === "in") { + this._timeout = setInterval(this.read.bind(this), this._pollingInterval); + } + } + catch (error) { + throw new OperationError(error); + } + this._direction = direction; + this._exported = true; + } + async unexport() { + clearInterval(this._timeout); + try { + await fs_1.promises.writeFile(`/sys/class/gpio/unexport`, parseUint16(this.portNumber.toString()).toString()); + } + catch (error) { + throw new OperationError(error); + } + this._exported = false; + } + async read() { + try { + const buffer = await fs_1.promises.readFile(`/sys/class/gpio/${parseUint16(this.portNumber.toString())}/value`); + const value = parseUint16(buffer.toString()); + if (this._value !== value) { + this._value = value; + this.emit("change", value); + } + return value; + } + catch (error) { + throw new OperationError(error); + } + } + async write(value) { + try { + await fs_1.promises.writeFile(`/sys/class/gpio/${parseUint16(this.portNumber.toString())}/value`, parseUint16(value.toString()).toString()); + } + catch (error) { + throw new OperationError(error); + } + } +} +exports.GPIOPort = GPIOPort; +class InvalidAccessError extends Error { +} +exports.InvalidAccessError = InvalidAccessError; +class OperationError extends Error { +} +exports.OperationError = OperationError; +async function requestGPIOAccess() { + const ports = new GPIOPortMap([...Array(Uint16Max + 1).keys()].map(portNumber => [ + portNumber, + new GPIOPort(portNumber) + ])); + return new GPIOAccess(ports); +} +exports.requestGPIOAccess = requestGPIOAccess; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..313766e --- /dev/null +++ b/index.ts @@ -0,0 +1,213 @@ +import { EventEmitter } from "events"; +import { promises as fs } from "fs"; + +/** + * Interval of file system polling, in milliseconds. + */ +const PollingInterval = 100; + +const Uint16Max = 65535; + +function parseUint16(string: string) { + const n = Number.parseInt(string, 10); + if (0 <= n && n <= Uint16Max) return n; + else throw new RangeError(`Must be between 0 and ${Uint16Max}.`); +} + +type PortNumber = number; +type PortName = string; +type PinName = string; + +type DirectionMode = "in" | "out"; + +type GPIOValue = 0 | 1; + +interface GPIOChangeEvent { + readonly value: GPIOValue; + readonly port: GPIOPort; +} + +interface GPIOChangeEventHandler { + (event: GPIOChangeEvent): void; +} + +/** + * Not a specification in Web GPIO API. + */ +interface GPIOPortChangeEventHandler { + (event: GPIOChangeEvent["value"]): void; +} + +export class GPIOAccess extends EventEmitter { + private readonly _ports: GPIOPortMap; + onchange: GPIOChangeEventHandler | undefined; + + constructor(ports?: GPIOPortMap) { + super(); + + this._ports = ports == null ? new GPIOPortMap() : ports; + this._ports.forEach(port => + port.on("change", value => { + const event: GPIOChangeEvent = { value, port }; + this.emit("change", event); + }) + ); + + this.on("change", (event: GPIOChangeEvent): void => { + if (this.onchange !== undefined) this.onchange(event); + }); + } + + get ports(): GPIOPortMap { + return this._ports; + } + + /** + * Unexport all exported GPIO ports. + */ + async unexportAll() { + await Promise.all( + [...this.ports.values()].map(port => + port.exported ? port.unexport() : undefined + ) + ); + } +} + +export class GPIOPortMap extends Map<PortNumber, GPIOPort> {} + +export class GPIOPort extends EventEmitter { + private readonly _portNumber: PortNumber; + private readonly _pollingInterval: number; + private _direction: DirectionMode | OperationError; + private _exported: boolean | OperationError; + private _value: GPIOValue | undefined; + private _timeout: ReturnType<typeof setInterval> | undefined; + onchange: GPIOPortChangeEventHandler | undefined; + + constructor(portNumber: PortNumber) { + super(); + + this._portNumber = portNumber; + this._pollingInterval = PollingInterval; + this._direction = new OperationError("Unknown direction."); + this._exported = new OperationError("Unknown export."); + + this.on("change", (value: GPIOChangeEvent["value"]): void => { + if (this.onchange !== undefined) this.onchange(value); + }); + } + + get portNumber(): PortNumber { + return this._portNumber; + } + + get portName(): PortName { + // NOTE: Unknown portName. + return ""; + } + + get pinName(): PinName { + // NOTE: Unknown pinName. + return ""; + } + + get direction(): DirectionMode { + if (this._direction instanceof OperationError) throw this._direction; + return this._direction; + } + + get exported(): boolean { + if (this._exported instanceof OperationError) throw this._exported; + return this._exported; + } + + async export(direction: DirectionMode) { + if (!/^(in|out)$/.test(direction)) { + throw new InvalidAccessError(`Must be "in" or "out".`); + } + + try { + clearInterval(this._timeout as any); + await fs.writeFile( + `/sys/class/gpio/export`, + parseUint16(this.portNumber.toString()).toString() + ); + await fs.writeFile( + `/sys/class/gpio/${parseUint16(this.portNumber.toString())}/direction`, + direction + ); + if (direction === "in") { + this._timeout = setInterval( + this.read.bind(this), + this._pollingInterval + ); + } + } catch (error) { + throw new OperationError(error); + } + + this._direction = direction; + this._exported = true; + } + + async unexport() { + clearInterval(this._timeout as any); + + try { + await fs.writeFile( + `/sys/class/gpio/unexport`, + parseUint16(this.portNumber.toString()).toString() + ); + } catch (error) { + throw new OperationError(error); + } + + this._exported = false; + } + + async read() { + try { + const buffer = await fs.readFile( + `/sys/class/gpio/${parseUint16(this.portNumber.toString())}/value` + ); + + const value = parseUint16(buffer.toString()) as GPIOValue; + + if (this._value !== value) { + this._value = value; + this.emit("change", value); + } + + return value; + } catch (error) { + throw new OperationError(error); + } + } + + async write(value: GPIOValue) { + try { + await fs.writeFile( + `/sys/class/gpio/${parseUint16(this.portNumber.toString())}/value`, + parseUint16(value.toString()).toString() + ); + } catch (error) { + throw new OperationError(error); + } + } +} + +export class InvalidAccessError extends Error {} + +export class OperationError extends Error {} + +export async function requestGPIOAccess(): Promise<GPIOAccess> { + const ports = new GPIOPortMap( + [...Array(Uint16Max + 1).keys()].map(portNumber => [ + portNumber, + new GPIOPort(portNumber) + ]) + ); + + return new GPIOAccess(ports); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b7e224b --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "@notweb/gpio", + "version": "0.0.1", + "description": "GPIO access with Node.js", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/kou029w/notweb-gpio.git" + }, + "author": "Kohei Watanabe <kou029w@gmail.com>", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/node": "^12.7.12", + "typescript": "^3.6.4" + }, + "scripts": { + "build": "tsc" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7c4531f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "strict": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..793c05a --- /dev/null +++ b/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/node@^12.7.12": + version "12.7.12" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.12.tgz#7c6c571cc2f3f3ac4a59a5f2bd48f5bdbc8653cc" + integrity sha512-KPYGmfD0/b1eXurQ59fXD1GBzhSQfz6/lKBxkaHX9dKTzjXbK68Zt7yGUxUsCS1jeTy/8aL+d9JEr+S54mpkWQ== + +typescript@^3.6.4: + version "3.6.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.6.4.tgz#b18752bb3792bc1a0281335f7f6ebf1bbfc5b91d" + integrity sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==