├── .github └── stale.yml ├── .gitignore ├── LICENSE ├── README.md ├── changelog.md ├── fakegato-history.js ├── fakegato-storage.js ├── fakegato-timer.js ├── lib ├── googleDrive.js └── uuid.js ├── package.json └── quickstartGoogleDrive.js /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | # Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable 19 | closeComment: false 20 | unmarkComment: false 21 | # Limit to only `issues` or `pulls` 22 | only: issues 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 simont77 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fakegato-history 2 | [![npm](https://img.shields.io/npm/v/fakegato-history.svg?style=plastic)](https://www.npmjs.com/package/fakegato-history) 3 | [![npm](https://img.shields.io/npm/dt/homebridge-weather-station-extended.svg?style=plastic)](https://www.npmjs.com/package/fakegato-history) 4 | [![GitHub last commit](https://img.shields.io/github/last-commit/simont77/fakegato-history.svg?style=plastic)](https://github.com/simont77/fakegato-history) 5 | [![GitHub license](https://img.shields.io/github/license/simont77/fakegato-history.svg?style=plastic)](https://github.com/simont77/fakegato-history) 6 | 7 | Module to emulate Elgato Eve history service in Homebridge accessories, so that it will show in Eve.app (Home.app does not support it). Still work in progress. Use at your own risk, no guarantee is provided. 8 | 9 | **NOTE when updating from version <0.5.0:** On certain systems (e.g. macOS), previus versions may append ".local" or ".lan" after *hostname* in the file name. This additional portions are now removed to improve reliability of persistence on google drive when network goes down. If you do not want to loose your previous history, before updating check if your system creates files with the additional portion, and if so, rename them. 10 | 11 | More details on communication protocol and custom Characteristics in the Wiki. 12 | 13 | Your plugin should expose the corresponding custom Elgato services and characteristics in order for the history to be seen in Eve.app. For a weather example see https://github.com/simont77/homebridge-weather-station-eve, for an energy example see https://github.com/simont77/homebridge-myhome/blob/master/index.js (MHPowerMeter class). For other types see the Wiki. 14 | Avoid the use of "/" in characteristics of the Information Service (e.g. model, serial number, manufacturer, etc.), since this may cause data to not appear in the history. Note that if your Eve.app is controlling more than one accessory for each type, the serial number should be unique, otherwise Eve.app will merge the histories. Adding hostname is recommended as well, for running multiple copies of the same plugin on different machines (i.e. production and development), i.e.: 15 | 16 | .setCharacteristic(Characteristic.SerialNumber, hostname + "-" + this.deviceID) 17 | 18 | Import module into your plugin module export with: 19 | 20 | var FakeGatoHistoryService = require('fakegato-history')(homebridge); 21 | 22 | Add the service to your Accessory using: 23 | 24 | Accessory.log = this.log; 25 | this.loggingService = new FakeGatoHistoryService(accessoryType, Accessory, length); 26 | 27 | And if your plugin is using V2 of the platform API, also add the above to your configureAccessory function as well. 28 | 29 | where 30 | 31 | - accessoryType can be "weather", "energy", "room", "room2, "door", motion", "switch", "thermo", "aqua", or "custom" 32 | - Accessory should be the accessory using the service, in order to correctly set the service name and pass the log to the parent object. Your Accessory should have a `this.log` variable pointing to the homebridge logger passed to the plugin constructor (add a line `this.log=log;` to your plugin). Debug messages will be shown if homebridge is launched with -D option. 33 | - length is the history length; if no value is given length is set to 4032 samples 34 | 35 | Remember to return the fakegato service in getServices function if using the accessory API, and if using the platform API include it as a Service as part of your accessory. 36 | 37 | Eve.app requires at least an entry every 10 minutes to avoid holes in the history. Depending on the accessory type, fakegato-history may add extra entries every 10 minutes or may average the entries from the plugin and send data every 10 minutes. This is done using a single global timer shared among all accessories using fakegato. You may opt for managing yourself the Timer and disabling the embedded one by using that constructor: 38 | ``` 39 | this.loggingService = new FakeGatoHistoryService(accessoryType, Accessory, {size:length,disableTimer:true}); 40 | ``` 41 | then you'll have to addEntry yourself data every 10min. 42 | 43 | By default, if you don't addEntry during the 10 minutes timer, to avoid gaps (and fill data for lazy sensors), the timer repeat the last data. To avoid this behaviour, add the `disableRepeatLastData` param : 44 | ``` 45 | this.loggingService = new FakeGatoHistoryService(accessoryType, Accessory, {size:length,disableRepeatLastData:true}); 46 | ``` 47 | 48 | Depending on your accessory type: 49 | 50 | * Add entries to history of accessory emulating **Eve Weather** (TempSensor Service) using something like this: 51 | 52 | this.loggingService.addEntry({time: Math.round(new Date().valueOf() / 1000), temp: this.temperature, pressure: this.airPressure, humidity: this.humidity}); 53 | 54 | AirPressure is in mbar, Temperature in Celsius, Humidity in %. Entries are internally averaged and sent every 10 minutes using the global fakegato timer. The temperature sensor needs to implement the `Characteristic.TemperatureDisplayUnits` Characteristic set to `Celsius`. Your entries should be in any case periodic, in order to avoid error with the average. Average is done independently on each quantity (i.e. you may different periods, and entries with only one or two quantities) 55 | 56 | * Add entries to history of accessory emulating **Eve Energy** (Outlet service) using something like this: 57 | 58 | this.loggingService.addEntry({time: Math.round(new Date().valueOf() / 1000), power: this.power}); 59 | 60 | Power is in Watt. Entries are internally averaged and sent every 10 minutes using the global fakegato timer. To have good accuracy, your entries should be in any case periodic, in order to avoid error with the average. 61 | 62 | * Add entries to history of accessory emulating **Eve Room** (TempSensor, HumiditySensor and AirQuality Services) using something like this: 63 | 64 | this.loggingService.addEntry({time: Math.round(new Date().valueOf() / 1000), temp: this.temperature, humidity: this.humidity, ppm: this.ppm}); 65 | 66 | Temperature in Celsius, Humidity in %. Entries are internally averaged and sent every 10 minutes using the global fakegato timer. Your entries should be in any case periodic, in order to avoid error with the average. Average is done independently on each quantity (i.e. you may different periods, and entries with only one or two quantities) 67 | 68 | * Add entries to history of accessory emulating **Eve Room 2** (TempSensor, HumiditySensor and AirQuality Services) using something like this: 69 | 70 | this.loggingService.addEntry({time: Math.round(new Date().valueOf() / 1000), temp: this.temperature, humidity: this.humidity, voc: this.voc}); 71 | 72 | Temperature in Celsius, Humidity in % and VOC in µg/m3. The Eve App will convert µg/m3 to ppb (by dividing by 4.57). Entries are internally averaged and sent every 10 minutes using the global fakegato timer. Your entries should be in any case periodic, in order to avoid error with the average. Average is done independently on each quantity (i.e. you may different periods, and entries with only one or two quantities) 73 | 74 | * Add entries to history of accessory emulating **Eve Door** (ContactSensor service) using something like this on every status change: 75 | 76 | this.loggingService.addEntry({time: Math.round(new Date().valueOf() / 1000), status: this.status}); 77 | 78 | Status can be 1 for ‘open’ or 0 for ‘close’. Entries are of type "event", so entries received from the plugin will be added to the history as is. In addition to that, fakegato will add extra entries every 10 minutes repeating the last known state, in order to avoid the appearance of holes in the history. 79 | 80 | * Add entries to history of accessory emulating **Eve Motion** (MotionSensor service) using something like this on every status change: 81 | 82 | this.loggingService.addEntry({time: Math.round(new Date().valueOf() / 1000), status: this.status}); 83 | 84 | Status can be 1 for ‘detected’ or 0 for ‘cleared’. Entries are of type "event", so entries received from the plugin will be added to the history as is. In addition to that, fakegato will add extra entries every 10 minutes repeating the last known state, in order to avoid the appearance of holes in the history. 85 | 86 | * Add entries to history of accessory emulating **Eve Light Switch** (Switch service) using something like this on every status change: 87 | 88 | this.loggingService.addEntry({time: Math.round(new Date().valueOf() / 1000), status: this.status}); 89 | 90 | Status can be 1 for ‘On’ or 0 for ‘Off’. Entries are of type "event", so entries received from the plugin will be added to the history as is. In addition to that, fakegato will add extra entries every 10 minutes repeating the last known state, in order to avoid the appearance of holes in the history. 91 | 92 | * Add entries to history of accessory emulating **Eve Thermo** (Thermostat service) using something like this every 10 minutes: 93 | 94 | this.loggingService.addEntry({time: Math.round(new Date().valueOf() / 1000), currentTemp: this.currentTemp, setTemp: this.setTemp, valvePosition: this.valvePosition}); 95 | 96 | currentTemp and setTemp in Celsius, valvePosition in %. Fakegato does not use the internal timer for Thermo, entries are added to the history as received from the plugin (Thermo accessory is under development). For setTemp to show, you have to add all the 3 extra thermo characteristics (see gist), and enable set temperature visualization under accessory options in Eve.app. 97 | 98 | * Add entries to history of accessory emulating **Eve Aqua** (Valve service set to Irrigation Type) using something like this on every status change: 99 | 100 | this.LoggingService.addEntry({ time: Math.round(new Date().valueOf() / 1000), status: this.power, waterAmount: this.waterAmount }); 101 | 102 | Status can be 1 for ‘open’ or 0 for ‘close’. WaterAmount is meaningful (and needed) only when Status is close, and corresponds to the amount of water used during the just elapsed irrigation period in ml. Entries are of type "event", so entries received from the plugin will be added to the history as is. In addition to that, fakegato will add extra entries every 10 minutes repeating the last known state, in order to avoid the appearance of holes in the history. 103 | 104 | * Add entries to history of an accessory of a **custom** design or configuration. Configurations validated include combination energy and switch device ( history of power and on/off ), motion and temperature device ( history of motion and temperature ), room and thermo device (history of temperature/humidity and setTemp/valvePosition), and motion and light sensor device ( history of motion and light level). 105 | 106 | this.LoggingService.addEntry({ time: Math.round(new Date().valueOf() / 1000), power: this.power }); 107 | this.LoggingService.addEntry({ time: Math.round(new Date().valueOf() / 1000), status: this.On }); 108 | 109 | This is a sample power / switch device, and in the sample I'm sending the current power usage then updating the on/off status of the device. For best results send power and on/off status separately. Power on a regular interval and on/off when the device status changes. 110 | 111 | Temperature, Humidity, Pressure, Power and lux entries are averaged over the history interval. Contact, Status, Motion, setTemp and valvePosition are directly added to history records. 112 | 113 | valid entry | Characteristic 114 | --- | --- 115 | temp | Temperature in celcius ( value averaged over 10 minutes ) 116 | humidity | humidity in percentage ( value averaged over 10 minutes ) 117 | pressure | pressure ( value averaged over 10 minutes ) 118 | power | Current usage in watts ( value averaged over 10 minutes ) 119 | ppm | Parts per million 120 | contact | contact sensor state ( 0 / 1 ) 121 | status | switch status ( 0 / 1 ) 122 | motion | motion sensor state ( 0 / 1 ) 123 | voc | µg/m3 124 | setTemp | Temperature in celcius 125 | valvePosition | valvePosition in percentage 126 | lux | light level in lux 127 | 128 | For Energy and Door accessories it is also worth to add the custom characteristic E863F112 for resetting, respectively, the Total Consumption accumulated value or the Aperture Counter (not the history). See Wiki. The value of this characteristic is changed whenever the reset button is tapped on Eve, so it can be used to reset the locally stored value. The value seems to be the number of seconds from 1.1.2001. I left this characteristics out of fakegato-history because it is not part of the common history service. 129 | 130 | For Door and Motion you may want to add characteristic E863F11A for setting the time of last activation. Value is the number of second from reset of fakegato-history. You can get this time using the function *getInitialTime()* 131 | 132 | For Aqua you need to add E863F131 and E863F11D characteristics in order to make Eve recognize the accessory, and to set last activation, total water consumption and flux (see wiki). You MUST also set a proper value in E863F131, even if your plugin does not actively set these quantities, otherwise Eve will not communicate properly. See wiki for an example proper value. 133 | 134 | If your "weather" or "room" plugin don't send addEntry for a short time (supposedly less than 1h - need feedback), the graph will draw a straight line from the last data received to the new data received. Instead, if your plugin don't send addEntry for "weather" and "room" for a long time (supposedly more than few hours - need feedback), the graph will show "no data for the period". Take this in consideration if your sensor does not send entries if the difference from the previous one is small, you will end up with holes in the history. This is not currently addresses by fakegato, you should add extra entries if needed. Note that if you do not send a new entry at least every 10 minutes, the average will be 0, and you will a zero entry. This will be fixed soon. 135 | 136 | ### Advanced Options 137 | 138 | * Usage in a Typescript based Plugin 139 | 140 | ``` 141 | import fakegato from 'fakegato-history'; 142 | . 143 | . 144 | . 145 | export class yourPlatform implements DynamicPlatformPlugin { 146 | private FakeGatoHistoryService; <-- You need a platform level reference to the service 147 | . 148 | . 149 | . 150 | constructor () <-- This is your Platform constructor 151 | { 152 | this.FakeGatoHistoryService = fakegato(this.api); 153 | . 154 | . 155 | . For each accessory 156 | element.fakegatoService = new this.FakeGatoHistoryService(element.type, accessory, { 157 | log: this.log, <-- Required as typescript does not allow adding the log variable to the Accessory object. 158 | }); 159 | ``` 160 | 161 | ### History Persistence 162 | 163 | It is possible to persist data to disk or to Google Drive to avoid loosing part of the history not yet downloaded by Eve on restart or system crash. Data is saved every 10min for "weather" and "room", on every event and every 10 minutes for "door" and "motion", on every event for other types. 164 | 165 | Data will be saved, either on local filesystem or on google drive, in JSON files, one for each persisted accessory, with filename in the form *hostname_accessoryDisplayName_persist.json*. In order to reset the persisted data, simply delete these files. 166 | 167 | **NOTE when updating from version <0.5.0:** On certain systems (e.g. macOS), previus versions may append ".local" or ".lan" after *hostname* in the file name. This additional portions are now removed to improve reliability of persistence on google drive when network goes down. If you do not want to loose your previous history, before updating check if your system creates files with the additional portion, and if so, rename them. 168 | 169 | As an added feature, plugins can leverage persistance capabilities of fakegato, both on filesystem and google drive, using the two functions *setExtraPersistedData(extra)* and *getExtraPersistedData()*. Extra can be any json formattable data. Plugin has to check that what is returned is what it is expecting (fakegato will return undefined object if extra data is not present on the persisted file, or if google drive has not started yet), and retry if needed. It is also advisable to call in advance the function *isHistoryLoaded()* to check whether fakegato finished loading the history from the storage. 170 | 171 | #### File System 172 | In order to enable persistence on local disk, when instantiating the FakeGatoHistoryService, the third argument become an object with these attributes: 173 | ``` 174 | this.loggingService = new FakeGatoHistoryService(accessoryType, Accessory, { 175 | size:length, // optional - if you still need to specify the length 176 | storage:'fs', 177 | path:'/place/to/store/my/persistence/' // if empty it will be used the -U homebridge option if present, or .homebridge in the user's home folder 178 | }); 179 | ``` 180 | 181 | #### Google Drive 182 | In order to enable persistence on Google Drive, when instantiating the FakeGatoHistoryService, the third argument become an object with these attributes: 183 | ``` 184 | this.loggingService = new FakeGatoHistoryService(accessoryType, Accessory, { 185 | size:length, // optional - if you still need to specify the length 186 | storage:'googleDrive', 187 | folder:'fakegatoFolder', // folder on Google drive to persist data, 'fakegato' if empty 188 | keyPath:'/place/to/store/my/keys/' // where to find client_secret.json, if empty it will be used the -U homebridge option if present or .homebridge 189 | }); 190 | ``` 191 | For the setup of Google Drive, please follow the Google Drive Quickstart for Node.js instructions from https://developers.google.com/drive/api/quickstart/nodejs, except for these changes: 192 | * In Step 7 of "Authorize credentials for a desktop application" rename the downloaded file to "client_secret.json" and put it in fakegato-history directory. 193 | * Skip "Setup the sample" 194 | * Run the quickstartGoogleDrive.js included with this module. You need to run the command from fakegato-history directory. After authoeizing the app onto Google website a file "drive-nodejs-quickstart.json" is created in the same directory 195 | * Copy files "client_secret.json" and "drive-nodejs-quickstart.json in your keyPath 196 | 197 | ##### Additional notes for Google Drive 198 | * Pay attention so that your plugin does not issue multiple addEntry calls for the same accessory at the same time (this may results in improper behaviour of Google Drive to the its asynchronous nature) 199 | 200 | ## TODO 201 | 202 | - [x] Support for rolling-over of the history 203 | - [x] Aggregate transmission of several entries into a single Characteristic update in order to speed up transfer when not on local network. 204 | - [x] Add other accessory types. Help from people with access to real Eve accessory is needed. Dump of custom Characteristics during data transfer is required. 205 | - [x] Make history persistent 206 | - [x] Adjustable history length 207 | - [ ] Addition and management of other history related characteristics 208 | - [ ] Periodic sending of reference time stamp (seems not really needed if the time of your homebridge machine is correct) 209 | 210 | ## Known bugs 211 | 212 | - Currently valve position history in thermo is not working 213 | 214 | ## How to contribute 215 | 216 | If you own an Eve accessory and would like to help improving this module, you can follow this procedure to dump the communication: 217 | 218 | - Install Xcode on a Mac with High Sierra and register for a free Apple Developer Account 219 | - Download this code https://github.com/simont77/HMCatalog-Swift3/tree/experimental and compile the HMCatalog app. Follow this guide https://stackoverflow.com/questions/30973799/ios-9-new-feature-free-provisioning-run-your-app-on-a-device-just-with-your-ap to compile for your own device and install on it (the app will not work on any other device). The App will run only for few days, since the code signature has a very short expiration date, but you can repeat the procedure if needed. This is called Free Provisioning, you may find many additional guides on the web in case of issues. You will have also to enable Homekit support, let Xcode fix issues when it offers to do it. 220 | - Run the HMCatalog app. You should be able to read raw values of all the characteristics of your accessories. 221 | - Trigger an history transfer of the history within Eve.app 222 | - Open again the HMCatalog app, select your Eve accessory and the Service E863F007-079E-48FF-8F27-9C2605A29F52. If using an iPad, you can open HMCatalog and Eve in split view to monitor in real time the communication as it occurs. 223 | - Copy values of all characteristics (especially E863F117-079E-48FF-8F27-9C2605A29F52 and E863F116-079E-48FF-8F27-9C2605A29F52) and leave me a comment with it. 224 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ### v0.6.0 2 | - Added support for custom accessory types which allow combo devices like a power sensor and switch or motion and temperature 3 | - Removed dependancy on moment package 4 | - Resolved Deprecation Warning with Buffer 5 | 6 | ### v0.5.6 7 | - Add type Switch 8 | 9 | ### v0.5.5 10 | - Update GoogleDrive dependency 11 | 12 | ### v0.5.4 13 | - Fix GoogleDrive issue 14 | 15 | ### v0.5.3 16 | - Added an optional parameter to disable the automatic repetition of the last entry every 10 minutes 17 | 18 | ### v0.5.2 19 | - Initial support for Aqua accessory type 20 | 21 | ### v0.5.1 22 | - Improves reliability when a new iDevice starts downloading an existing history that has already rolled up. 23 | - Added isHistoryLoaded() function 24 | 25 | ### v0.5.0 26 | - Fixes for google drive availability issues (#54). NOTE: On certain systems (e.g. macOS), previus versions may append ".local" or ".lan" after *hostname* in the file name. This additional portions are now removed to improve reliability of persistence on google drive when network goes down. If you do not want to loose your previous history, before updating check if your system creates files with the additional portion, and if so, rename them. 27 | - Added possibility to leverage fakegato persistance capability to save extra user data 28 | 29 | ### v0.4.3 30 | - fix for "room" when internal timer is not enabled 31 | 32 | ### v0.4.2 33 | - fix bug when internal timer is not enabled 34 | - add optional parameter for filename 35 | 36 | ### v0.4.1 37 | - fix filesystem persist location when -U option is used in homebridge 38 | 39 | ### v0.4.0 40 | - added ability to persist history either to filesystem or to google drive 41 | - added option to disable internal timer 42 | - various fixes on internal timer average calculation 43 | - now also Energy uses the global internal timer 44 | - added initialTime and getter, for external management of characteristics 11A (last opening/activation on Door/Motion) 45 | 46 | ### v0.3.8 47 | - improve protocol to ensure prompt download of data even if Eve is missing a single entry (before this commit, two new entries were necessary for Eve to start downloading) 48 | 49 | ### v0.3.7 50 | - fix to allow showing last activation in Door and Motion 51 | 52 | ### v0.3.6 53 | - added compatibility with platfoms 54 | 55 | ### v0.3.5 56 | - added internal global timer 57 | 58 | ### v0.3.4 59 | - added Door, Motion, Room and Thermo support 60 | 61 | ### v0.3.3 62 | - cleanup 63 | 64 | ### v0.3.2 65 | - first NPM release 66 | 67 | ### v0.3.1 68 | - first fully working version 69 | 70 | ### v0.3.0 71 | - update readme, fix package.json 72 | - added transmission of several entries per query 73 | 74 | ### v0.2 75 | - added support for memory rolling 76 | 77 | ### v0.1 78 | - initial commit (only Energy and Weather) 79 | -------------------------------------------------------------------------------- /fakegato-history.js: -------------------------------------------------------------------------------- 1 | /*jshint esversion: 6,node: true,-W041: false */ 2 | 'use strict'; 3 | 4 | const Format = require('util').format; 5 | const FakeGatoTimer = require('./fakegato-timer').FakeGatoTimer; 6 | const FakeGatoStorage = require('./fakegato-storage').FakeGatoStorage; 7 | 8 | const EPOCH_OFFSET = 978307200; 9 | 10 | const TYPE_ENERGY = 'energy', 11 | TYPE_ROOM = 'room', 12 | TYPE_ROOM2 = 'room2', 13 | TYPE_WEATHER = 'weather', 14 | TYPE_DOOR = 'door', 15 | TYPE_MOTION = 'motion', 16 | TYPE_SWITCH = 'switch', 17 | TYPE_THERMO = 'thermo', 18 | TYPE_AQUA = 'aqua', 19 | TYPE_CUSTOM = 'custom'; 20 | 21 | var homebridge; 22 | var Characteristic, Service, Formats, Perms; 23 | 24 | module.exports = function (pHomebridge) { 25 | if (pHomebridge && !homebridge) { 26 | homebridge = pHomebridge; 27 | Characteristic = homebridge.hap.Characteristic; 28 | Service = homebridge.hap.Service; 29 | Formats = homebridge.hap.Formats; 30 | Perms = homebridge.hap.Perms; 31 | } 32 | 33 | 34 | var hexToBase64 = function (val) { 35 | return Buffer.from(('' + val).replace(/[^0-9A-F]/ig, ''), 'hex').toString('base64'); 36 | }, 37 | base64ToHex = function (val) { 38 | if (!val) 39 | return val; 40 | return Buffer.from(val, 'base64').toString('hex'); 41 | }, 42 | swap16 = function (val) { 43 | return ((val & 0xFF) << 8) 44 | | ((val >>> 8) & 0xFF); 45 | }, 46 | swap32 = function (val) { 47 | return ((val & 0xFF) << 24) 48 | | ((val & 0xFF00) << 8) 49 | | ((val >>> 8) & 0xFF00) 50 | | ((val >>> 24) & 0xFF); 51 | }, 52 | hexToHPA = function (val) { //unused 53 | return parseInt(swap16(val), 10); 54 | }, 55 | hPAtoHex = function (val) { //unused 56 | return swap16(Math.round(val)).toString(16); 57 | }, 58 | numToHex = function (val, len) { 59 | var s = Number(val >>> 0).toString(16); 60 | if (s.length % 2 != 0) { 61 | s = '0' + s; 62 | } 63 | if (len) { 64 | return ('0000000000000' + s).slice(-1 * len); 65 | } 66 | return s; 67 | }, 68 | ucfirst = function (val) { 69 | return val.charAt(0).toUpperCase() + val.substr(1); 70 | }, 71 | precisionRound = function (number, precision) { 72 | var factor = Math.pow(10, precision); 73 | return Math.round(number * factor) / factor; 74 | }; 75 | 76 | class S2R1Characteristic extends Characteristic { 77 | constructor() { 78 | super('S2R1', S2R1Characteristic.UUID); 79 | this.setProps({ 80 | format: Formats.DATA, 81 | perms: [ 82 | Perms.PAIRED_READ, Perms.NOTIFY, Perms.HIDDEN 83 | ] 84 | }); 85 | } 86 | } 87 | 88 | S2R1Characteristic.UUID = 'E863F116-079E-48FF-8F27-9C2605A29F52'; 89 | 90 | class S2R2Characteristic extends Characteristic { 91 | constructor() { 92 | super('S2R2', S2R2Characteristic.UUID); 93 | this.setProps({ 94 | format: Formats.DATA, 95 | perms: [ 96 | Perms.PAIRED_READ, Perms.NOTIFY, Perms.HIDDEN 97 | ] 98 | }); 99 | } 100 | } 101 | 102 | S2R2Characteristic.UUID = 'E863F117-079E-48FF-8F27-9C2605A29F52'; 103 | 104 | class S2W1Characteristic extends Characteristic { 105 | constructor() { 106 | super('S2W1', S2W1Characteristic.UUID); 107 | this.setProps({ 108 | format: Formats.DATA, 109 | perms: [ 110 | Perms.PAIRED_WRITE, Perms.HIDDEN 111 | ] 112 | }); 113 | } 114 | } 115 | 116 | S2W1Characteristic.UUID = 'E863F11C-079E-48FF-8F27-9C2605A29F52'; 117 | 118 | class S2W2Characteristic extends Characteristic { 119 | constructor() { 120 | super('S2W2', S2W2Characteristic.UUID); 121 | this.setProps({ 122 | format: Formats.DATA, 123 | perms: [ 124 | Perms.PAIRED_WRITE, Perms.HIDDEN 125 | ] 126 | }); 127 | } 128 | } 129 | 130 | S2W2Characteristic.UUID = 'E863F121-079E-48FF-8F27-9C2605A29F52'; 131 | 132 | class FakeGatoHistoryService extends Service { 133 | constructor(displayName, subtype) { 134 | super(displayName, FakeGatoHistoryService.UUID, subtype); 135 | 136 | this.addCharacteristic(S2R1Characteristic); 137 | this.addCharacteristic(S2R2Characteristic); 138 | this.addCharacteristic(S2W1Characteristic); 139 | this.addCharacteristic(S2W2Characteristic); 140 | } 141 | } 142 | 143 | FakeGatoHistoryService.UUID = 'E863F007-079E-48FF-8F27-9C2605A29F52'; 144 | var thisAccessory = {}; 145 | class FakeGatoHistory extends Service { 146 | constructor(accessoryType, accessory, optionalParams) { 147 | 148 | super(accessory.displayName + " History", FakeGatoHistoryService.UUID); 149 | 150 | var entry2address = function (val) { // not used ? 151 | var temp = val % this.memorySize; 152 | return temp; 153 | }.bind(this); 154 | 155 | thisAccessory = accessory; 156 | this.accessoryName = thisAccessory.displayName; 157 | this.signatures = []; 158 | this.uuid = require('./lib/uuid.js'); 159 | 160 | if (typeof (optionalParams) === 'object') { 161 | this.size = optionalParams.size || 4032; 162 | this.minutes = optionalParams.minutes || 10; // Optional timer length 163 | this.storage = optionalParams.storage; // 'fs' or 'googleDrive' 164 | this.path = optionalParams.path || optionalParams.folder || (this.storage == 'fs' ? homebridge.user.storagePath() : undefined); 165 | this.filename = optionalParams.filename; 166 | this.disableTimer = optionalParams.disableTimer || false; 167 | this.disableRepeatLastData = optionalParams.disableRepeatLastData || false; 168 | this.log = optionalParams.log || thisAccessory.log || {}; // workaround for typescript blocking of changing of accessory object definition 169 | } else { 170 | this.size = 4032; 171 | this.minutes = 10; 172 | this.disableTimer = false; 173 | this.log = thisAccessory.log || {}; 174 | } 175 | 176 | if (!this.log.debug) { 177 | this.log.debug = function () { }; 178 | } 179 | 180 | if (!this.disableTimer) { 181 | if (homebridge.globalFakeGatoTimer === undefined) 182 | homebridge.globalFakeGatoTimer = new FakeGatoTimer({ 183 | minutes: this.minutes, 184 | log: this.log 185 | }); 186 | } 187 | 188 | if (this.storage !== undefined) { 189 | this.loaded = false; 190 | if (homebridge.globalFakeGatoStorage === undefined) { 191 | homebridge.globalFakeGatoStorage = new FakeGatoStorage({ 192 | log: this.log 193 | }); 194 | } 195 | homebridge.globalFakeGatoStorage.addWriter(this, { 196 | storage: this.storage, 197 | path: this.path, 198 | filename: this.filename, 199 | keyPath: optionalParams.keyPath || homebridge.user.storagePath() || undefined, 200 | onReady: function () { 201 | 202 | this.load(function (err, loaded) { 203 | //this.log.debug("**Fakegato-history Loaded",loaded); 204 | //this.registerEvents(); 205 | if (err) this.log.debug('**Fakegato-history Load error :', err); 206 | else { 207 | if (loaded) this.log.debug('**Fakegato-history History Loaded from Persistant Storage'); 208 | this.loaded = true; 209 | } 210 | }.bind(this)); 211 | }.bind(this) 212 | }); 213 | } 214 | 215 | 216 | switch (accessoryType) { 217 | case TYPE_WEATHER: 218 | this.accessoryType116 = "03 0102 0202 0302"; 219 | this.accessoryType117 = "07"; 220 | if (!this.disableTimer) { 221 | homebridge.globalFakeGatoTimer.subscribe(this, this.calculateAverage); 222 | } 223 | break; 224 | case TYPE_ENERGY: 225 | this.accessoryType116 = "04 0102 0202 0702 0f03"; 226 | this.accessoryType117 = "1f"; 227 | if (!this.disableTimer) { 228 | homebridge.globalFakeGatoTimer.subscribe(this, this.calculateAverage); 229 | } 230 | break; 231 | case TYPE_ROOM: 232 | this.accessoryType116 = "04 0102 0202 0402 0f03"; 233 | this.accessoryType117 = "0f"; 234 | if (!this.disableTimer) { 235 | homebridge.globalFakeGatoTimer.subscribe(this, this.calculateAverage); 236 | } 237 | break; 238 | case TYPE_ROOM2: 239 | this.accessoryType116 = "07 0102 0202 2202 2901 2501 2302 2801"; 240 | this.accessoryType117 = "7f"; 241 | if (!this.disableTimer) { 242 | homebridge.globalFakeGatoTimer.subscribe(this, this.calculateAverage); 243 | } 244 | break; 245 | case TYPE_DOOR: 246 | this.accessoryType116 = "01 0601"; 247 | this.accessoryType117 = "01"; 248 | if (!this.disableTimer) { 249 | homebridge.globalFakeGatoTimer.subscribe(this, function (params) { // callback 250 | var backLog = params.backLog || []; 251 | var immediate = params.immediate; 252 | 253 | var fakegato = this.service; 254 | var actualEntry = {}; 255 | 256 | if (backLog.length) { 257 | if (!immediate) { 258 | actualEntry.time = Math.round(new Date().valueOf() / 1000); 259 | actualEntry.status = backLog[0].status; 260 | } 261 | else { 262 | actualEntry.time = backLog[0].time; 263 | actualEntry.status = backLog[0].status; 264 | } 265 | fakegato.log.debug('**Fakegato-timer callbackDoor: ', fakegato.accessoryName, ', immediate: ', immediate, ', entry: ', actualEntry); 266 | 267 | fakegato._addEntry(actualEntry); 268 | } 269 | }); 270 | } 271 | break; 272 | case TYPE_MOTION: 273 | this.accessoryType116 = "02 1301 1c01"; 274 | this.accessoryType117 = "02"; 275 | if (!this.disableTimer) { 276 | homebridge.globalFakeGatoTimer.subscribe(this, function (params) { // callback 277 | var backLog = params.backLog || []; 278 | var immediate = params.immediate; 279 | 280 | var fakegato = this.service; 281 | var actualEntry = {}; 282 | 283 | if (backLog.length) { 284 | if (!immediate) { 285 | actualEntry.time = Math.round(new Date().valueOf() / 1000); 286 | actualEntry.status = backLog[0].status; 287 | } 288 | else { 289 | actualEntry.time = backLog[0].time; 290 | actualEntry.status = backLog[0].status; 291 | } 292 | fakegato.log.debug('**Fakegato-timer callbackMotion: ', fakegato.accessoryName, ', immediate: ', immediate, ', entry: ', actualEntry); 293 | 294 | fakegato._addEntry(actualEntry); 295 | } 296 | }); 297 | } 298 | break; 299 | case TYPE_SWITCH: 300 | this.accessoryType116 = "01 0e01"; 301 | this.accessoryType117 = "01"; 302 | if (!this.disableTimer) { 303 | homebridge.globalFakeGatoTimer.subscribe(this, function (params) { // callback 304 | var backLog = params.backLog || []; 305 | var immediate = params.immediate; 306 | 307 | var fakegato = this.service; 308 | var actualEntry = {}; 309 | 310 | if (backLog.length) { 311 | if (!immediate) { 312 | actualEntry.time = Math.round(new Date().valueOf() / 1000); 313 | actualEntry.status = backLog[0].status; 314 | } 315 | else { 316 | actualEntry.time = backLog[0].time; 317 | actualEntry.status = backLog[0].status; 318 | } 319 | fakegato.log.debug('**Fakegato-timer callbackSwitch: ', fakegato.accessoryName, ', immediate: ', immediate, ', entry: ', actualEntry); 320 | 321 | fakegato._addEntry(actualEntry); 322 | } 323 | }); 324 | } 325 | break; 326 | case TYPE_CUSTOM: 327 | thisAccessory.services.forEach((service, i) => { 328 | service.characteristics.forEach((characteristic, i) => { 329 | // console.log('**Fakegato-history characteristics', characteristic.displayName, characteristic.UUID); 330 | switch(this.uuid.toLongFormUUID(characteristic.UUID)) { 331 | case Characteristic.CurrentTemperature.UUID: // Temperature 332 | this.signatures.push({ signature: '0102', length: 4, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 100, entry: "temp" }); 333 | break; 334 | case Characteristic.VOCDensity.UUID: // VOC Density 335 | this.signatures.push({ signature: '2202', length: 4, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 1, entry: "voc" }); 336 | break; 337 | case Characteristic.CurrentRelativeHumidity.UUID: // Humidity 338 | this.signatures.push({ signature: '0202', length: 4, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 100, entry: "humidity" }); 339 | break; 340 | case 'E863F10F-079E-48FF-8F27-9C2605A29F52': // CustomCharacteristic.AtmosphericPressureLevel.UUID 341 | this.signatures.push({ signature: '0302', length: 4, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 10, entry: "pressure" }); 342 | break; 343 | case 'E863F10B-079E-48FF-8F27-9C2605A29F52': // PPM 344 | this.signatures.push({ signature: '0702', length: 4, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 10, entry: "ppm" }); 345 | break; 346 | case Characteristic.ContactSensorState.UUID: // Contact Sensor State 347 | this.signatures.push({ signature: '0601', length: 2, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 1, entry: "contact" }); 348 | break; 349 | case 'E863F10D-079E-48FF-8F27-9C2605A29F52': // Power 350 | this.signatures.push({ signature: '0702', length: 4, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 10, entry: "power" }); 351 | break; 352 | case Characteristic.On.UUID: // Switch On 353 | this.signatures.push({ signature: '0e01', length: 2, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 1, entry: "status" }); 354 | break; 355 | case Characteristic.MotionDetected.UUID: // Motion Detected 356 | this.signatures.push({ signature: '1c01', length: 2, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 1, entry: "motion" }); 357 | break; 358 | case Characteristic.TargetTemperature.UUID: // Target Temperature 359 | this.signatures.push({ signature: '1102', length: 4, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 100, entry: "setTemp" }); 360 | break; 361 | case 'E863F12E-079E-48FF-8F27-9C2605A29F52': // Valve Position 362 | this.signatures.push({ signature: '1001', length: 2, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 1, entry: "valvePosition" }); 363 | break; 364 | case '0000006B-0000-1000-8000-0026BB765291': // CurrentAmbiantLightLevel 365 | this.signatures.push({ signature: '3002', length: 4, uuid: this.uuid.toShortFormUUID(characteristic.UUID), factor: 1, entry: "lux" }); 366 | break; 367 | } 368 | }); 369 | }); 370 | this.accessoryType116 = (' 0' + this.signatures.length.toString() + ' ' + this.signatures.sort((a, b) => (a.signature > b.signature) ? 1 : -1).map(a => a.signature).join(' ') + ' '); 371 | if (!this.disableTimer) { 372 | homebridge.globalFakeGatoTimer.subscribe(this, this.calculateAverage); 373 | } 374 | break; 375 | case TYPE_AQUA: 376 | this.accessoryType116 = "03 1f01 2a08 2302"; 377 | this.accessoryType117 = "05"; 378 | this.accessoryType117bis = "07"; 379 | break; 380 | case TYPE_THERMO: 381 | this.accessoryType116 = "05 0102 1102 1001 1201 1d01"; 382 | this.accessoryType117 = "1f"; 383 | break; 384 | } 385 | 386 | this.accessoryType = accessoryType; 387 | this.firstEntry = 0; 388 | this.lastEntry = 0; 389 | this.history = ["noValue"]; 390 | this.memorySize = this.size; 391 | this.usedMemory = 0; 392 | this.currentEntry = 1; 393 | this.transfer = false; 394 | this.setTime = true; 395 | this.restarted = true; 396 | this.refTime = 0; 397 | this.memoryAddress = 0; 398 | this.dataStream = ''; 399 | 400 | this.saving = false; 401 | 402 | this.registerEvents(); 403 | if (this.storage === undefined) { 404 | this.loaded = true; 405 | } 406 | } 407 | 408 | calculateAverage(params) { // callback 409 | var backLog = params.backLog || []; 410 | var previousAvrg = params.previousAvrg || {}; 411 | var timer = params.timer; 412 | 413 | var fakegato = this.service; 414 | var calc = { 415 | sum: {}, 416 | num: {}, 417 | avrg: {} 418 | }; 419 | 420 | for (var h in backLog) { 421 | if (backLog.hasOwnProperty(h)) { // only valid keys 422 | for (let key in backLog[h]) { // each record 423 | if (backLog[h].hasOwnProperty(key) && key != 'time') { // except time 424 | if (!calc.sum[key]) 425 | calc.sum[key] = 0; 426 | if (!calc.num[key]) 427 | calc.num[key] = 0; 428 | calc.sum[key] += backLog[h][key]; 429 | calc.num[key]++; 430 | calc.avrg[key] = precisionRound(calc.sum[key] / calc.num[key], 2); 431 | if (key == 'voc') // VOC expects integers 432 | calc.avrg[key] = Math.round(calc.avrg[key]); 433 | } 434 | } 435 | } 436 | } 437 | calc.avrg.time = Math.round(new Date().valueOf() / 1000); // set the time of the avrg 438 | 439 | if(!fakegato.disableRepeatLastData) { 440 | for (let key in previousAvrg) { // each record of previous average 441 | if (previousAvrg.hasOwnProperty(key) && key != 'time') { // except time 442 | if (!backLog.length ||//calc.avrg[key] == 0 || // zero value 443 | calc.avrg[key] === undefined) // no key (meaning no value received for this key yet) 444 | { 445 | calc.avrg[key] = previousAvrg[key]; 446 | } 447 | } 448 | } 449 | } 450 | 451 | if (Object.keys(calc.avrg).length > 1) { 452 | fakegato._addEntry(calc.avrg); 453 | timer.emptyData(fakegato); 454 | } 455 | return calc.avrg; 456 | } 457 | 458 | registerEvents() { 459 | this.log.debug('**Fakegato-history Registring Events', thisAccessory.displayName); 460 | if (typeof thisAccessory.getService === "function") { 461 | // Platform API 462 | this.log.debug('**Fakegato-history Platform', thisAccessory.displayName); 463 | 464 | this.service = thisAccessory.getService(FakeGatoHistoryService); 465 | if (this.service === undefined) { 466 | this.service = thisAccessory.addService(FakeGatoHistoryService, ucfirst(thisAccessory.displayName) + ' History', this.accessoryType); 467 | } 468 | 469 | this.service.getCharacteristic(S2R2Characteristic) 470 | .on('get', this.getCurrentS2R2.bind(this)); 471 | 472 | this.service.getCharacteristic(S2W1Characteristic) 473 | .on('set', this.setCurrentS2W1.bind(this)); 474 | 475 | this.service.getCharacteristic(S2W2Characteristic) 476 | .on('set', this.setCurrentS2W2.bind(this)); 477 | 478 | } 479 | else { 480 | // Accessory API 481 | this.log.debug('**Fakegato-history Accessory', thisAccessory.displayName); 482 | 483 | this.addCharacteristic(S2R1Characteristic); 484 | 485 | this.addCharacteristic(S2R2Characteristic) 486 | .on('get', this.getCurrentS2R2.bind(this)); 487 | 488 | this.addCharacteristic(S2W1Characteristic) 489 | .on('set', this.setCurrentS2W1.bind(this)); 490 | 491 | this.addCharacteristic(S2W2Characteristic) 492 | .on('set', this.setCurrentS2W2.bind(this)); 493 | } 494 | } 495 | 496 | sendHistory(address) { 497 | if (address != 0) { 498 | this.currentEntry = address; 499 | } else { 500 | this.currentEntry = 1; 501 | } 502 | this.transfer = true; 503 | } 504 | 505 | addEntry(entry) { 506 | switch (this.accessoryType) { 507 | case TYPE_DOOR: 508 | case TYPE_MOTION: 509 | case TYPE_SWITCH: 510 | if (!this.disableTimer) 511 | homebridge.globalFakeGatoTimer.addData({ entry: entry, service: this, immediateCallback: true }); 512 | else 513 | this._addEntry({ time: entry.time, status: entry.status }); 514 | break; 515 | case TYPE_AQUA: 516 | this._addEntry({ time: entry.time, status: entry.status, waterAmount: entry.waterAmount }); 517 | break; 518 | case TYPE_WEATHER: 519 | if (!this.disableTimer) 520 | homebridge.globalFakeGatoTimer.addData({ entry: entry, service: this }); 521 | else 522 | this._addEntry({ time: entry.time, temp: entry.temp, humidity: entry.humidity, pressure: entry.pressure }); 523 | break; 524 | case TYPE_ROOM: 525 | if (!this.disableTimer) 526 | homebridge.globalFakeGatoTimer.addData({ entry: entry, service: this }); 527 | else 528 | this._addEntry({ time: entry.time, temp: entry.temp, humidity: entry.humidity, ppm: entry.ppm }); 529 | break; 530 | case TYPE_ROOM2: 531 | if (!this.disableTimer) 532 | homebridge.globalFakeGatoTimer.addData({ entry: entry, service: this }); 533 | else 534 | this._addEntry({ time: entry.time, temp: entry.temp, humidity: entry.humidity, voc: entry.voc }); 535 | break; 536 | case TYPE_ENERGY: 537 | if (!this.disableTimer) 538 | homebridge.globalFakeGatoTimer.addData({ entry: entry, service: this }); 539 | else 540 | this._addEntry({ time: entry.time, power: entry.power }); 541 | break; 542 | case TYPE_CUSTOM: 543 | if (!this.disableTimer) 544 | if ('power' in entry || 'temp' in entry || 'lux' in entry) { // Only put power, temperature or lux thru averager 545 | homebridge.globalFakeGatoTimer.addData({ entry: entry, service: this }); 546 | } else { 547 | this._addEntry(entry); 548 | } 549 | else 550 | this._addEntry(entry); 551 | break; 552 | default: 553 | this._addEntry(entry); 554 | break; 555 | } 556 | } 557 | 558 | //in order to be consistent with Eve, entry address start from 1 559 | _addEntry(entry) { 560 | if (this.loaded) { 561 | var entry2address = function (val) { 562 | return val % this.memorySize; 563 | } 564 | .bind(this); 565 | 566 | var val; 567 | 568 | if (this.usedMemory < this.memorySize) { 569 | this.usedMemory++; 570 | this.firstEntry = 0; 571 | this.lastEntry = this.usedMemory; 572 | } else { 573 | this.firstEntry++; 574 | this.lastEntry = this.firstEntry + this.usedMemory; 575 | if (this.restarted == true) { 576 | this.history[entry2address(this.lastEntry)] = { 577 | time: entry.time, 578 | setRefTime: 1 579 | }; 580 | this.firstEntry++; 581 | this.lastEntry = this.firstEntry + this.usedMemory; 582 | this.restarted = false; 583 | } 584 | } 585 | 586 | if (this.refTime == 0) { 587 | this.refTime = entry.time - EPOCH_OFFSET; 588 | this.history[this.lastEntry] = { 589 | time: entry.time, 590 | setRefTime: 1 591 | }; 592 | this.initialTime = entry.time; 593 | this.lastEntry++; 594 | this.usedMemory++; 595 | } 596 | 597 | this.history[entry2address(this.lastEntry)] = (entry); 598 | 599 | if (this.usedMemory < this.memorySize) { 600 | val = Format( 601 | '%s00000000%s%s%s%s%s000000000101', 602 | numToHex(swap32(entry.time - this.refTime - EPOCH_OFFSET), 8), 603 | numToHex(swap32(this.refTime), 8), 604 | this.accessoryType116, 605 | numToHex(swap16(this.usedMemory + 1), 4), 606 | numToHex(swap16(this.memorySize), 4), 607 | numToHex(swap32(this.firstEntry), 8)); 608 | } else { 609 | val = Format( 610 | '%s00000000%s%s%s%s%s000000000101', 611 | numToHex(swap32(entry.time - this.refTime - EPOCH_OFFSET), 8), 612 | numToHex(swap32(this.refTime), 8), 613 | this.accessoryType116, 614 | numToHex(swap16(this.usedMemory), 4), 615 | numToHex(swap16(this.memorySize), 4), 616 | numToHex(swap32(this.firstEntry + 1), 8)); 617 | } 618 | 619 | if (this.service === undefined) { // Accessory API 620 | this.getCharacteristic(S2R1Characteristic).setValue(hexToBase64(val)); 621 | } 622 | else { // Platform API 623 | this.service.getCharacteristic(S2R1Characteristic).setValue(hexToBase64(val)); 624 | } 625 | 626 | this.log.debug("**Fakegato-history First entry %s: %s", this.accessoryName, this.firstEntry.toString(16)); 627 | this.log.debug("**Fakegato-history Last entry %s: %s", this.accessoryName, this.lastEntry.toString(16)); 628 | this.log.debug("**Fakegato-history Used memory %s: %s", this.accessoryName, this.usedMemory.toString(16)); 629 | this.log.debug("**Fakegato-history Val 116 %s: %s", this.accessoryName, val); 630 | 631 | if (this.storage !== undefined) this.save(); 632 | } else { 633 | setTimeout(function () { // retry in 100ms 634 | this._addEntry(entry); 635 | }.bind(this), 100); 636 | } 637 | } 638 | getInitialTime() { 639 | return this.initialTime; 640 | } 641 | 642 | setExtraPersistedData(extra) { 643 | this.extra = extra; 644 | } 645 | 646 | getExtraPersistedData() { 647 | return this.extra; 648 | } 649 | 650 | isHistoryLoaded() { 651 | return this.loaded; 652 | } 653 | 654 | save() { 655 | if (this.loaded) { 656 | 657 | let data = { 658 | firstEntry: this.firstEntry, 659 | lastEntry: this.lastEntry, 660 | usedMemory: this.usedMemory, 661 | refTime: this.refTime, 662 | initialTime: this.initialTime, 663 | history: this.history, 664 | extra: this.extra 665 | }; 666 | 667 | 668 | homebridge.globalFakeGatoStorage.write({ 669 | service: this, 670 | data: typeof (data) === "object" ? JSON.stringify(data) : data 671 | }); 672 | 673 | } else { 674 | setTimeout(function () { // retry in 100ms 675 | this.save(); 676 | }.bind(this), 100); 677 | } 678 | } 679 | load(cb) { 680 | this.log.debug("**Fakegato-history Loading..."); 681 | homebridge.globalFakeGatoStorage.read({ 682 | service: this, 683 | callback: function (err, data) { 684 | if (!err) { 685 | if (data) { 686 | try { 687 | this.log.debug("**Fakegato-history read data from", this.accessoryName, ":", data); 688 | let jsonFile = typeof (data) === "object" ? data : JSON.parse(data); 689 | 690 | this.firstEntry = jsonFile.firstEntry; 691 | this.lastEntry = jsonFile.lastEntry; 692 | this.usedMemory = jsonFile.usedMemory; 693 | this.refTime = jsonFile.refTime; 694 | this.initialTime = jsonFile.initialTime; 695 | this.history = jsonFile.history; 696 | this.extra = jsonFile.extra; 697 | } catch (e) { 698 | this.log.debug("**Fakegato-history ERROR fetching persisting data restart from zero - invalid JSON**", e); 699 | cb(e, false); 700 | } 701 | cb(null, true); 702 | } 703 | } else { 704 | // file don't exists 705 | cb(null, false); 706 | } 707 | }.bind(this) 708 | }); 709 | } 710 | cleanPersist() { 711 | this.log.debug("**Fakegato-history Cleaning..."); 712 | homebridge.globalFakeGatoStorage.remove({ 713 | service: this 714 | }); 715 | } 716 | 717 | getCurrentS2R2(callback) { 718 | var entry2address = function (val) { 719 | return val % this.memorySize; 720 | }.bind(this); 721 | 722 | if ((this.currentEntry <= this.lastEntry) && (this.transfer == true)) { 723 | this.memoryAddress = entry2address(this.currentEntry); 724 | for (var i = 0; i < 11; i++) { 725 | if ((this.history[this.memoryAddress].setRefTime == 1) || (this.setTime == true) || 726 | (this.currentEntry == this.firstEntry + 1)) { 727 | this.dataStream += Format( 728 | ",15%s 0100 0000 81%s0000 0000 00 0000", 729 | numToHex(swap32(this.currentEntry), 8), 730 | numToHex(swap32(this.refTime), 8)); 731 | this.setTime = false; 732 | } 733 | else { 734 | this.log.debug("**Fakegato-history %s Entry: %s, Address: %s", this.accessoryName, this.currentEntry, this.memoryAddress); 735 | switch (this.accessoryType) { 736 | case TYPE_WEATHER: 737 | this.dataStream += Format( 738 | ",10 %s%s-%s:%s %s %s", 739 | numToHex(swap32(this.currentEntry), 8), 740 | numToHex(swap32(this.history[this.memoryAddress].time - this.refTime - EPOCH_OFFSET), 8), 741 | this.accessoryType117, 742 | numToHex(swap16(this.history[this.memoryAddress].temp * 100), 4), 743 | numToHex(swap16(this.history[this.memoryAddress].humidity * 100), 4), 744 | numToHex(swap16(this.history[this.memoryAddress].pressure * 10), 4)); 745 | break; 746 | case TYPE_ENERGY: 747 | this.dataStream += Format( 748 | ",14 %s%s-%s:0000 0000 %s 0000 0000", 749 | numToHex(swap32(this.currentEntry), 8), 750 | numToHex(swap32(this.history[this.memoryAddress].time - this.refTime - EPOCH_OFFSET), 8), 751 | this.accessoryType117, 752 | numToHex(swap16(this.history[this.memoryAddress].power * 10), 4)); 753 | break; 754 | case TYPE_ROOM: 755 | this.dataStream += Format( 756 | ",13 %s%s%s%s%s%s0000 00", 757 | numToHex(swap32(this.currentEntry), 8), 758 | numToHex(swap32(this.history[this.memoryAddress].time - this.refTime - EPOCH_OFFSET), 8), 759 | this.accessoryType117, 760 | numToHex(swap16(this.history[this.memoryAddress].temp * 100), 4), 761 | numToHex(swap16(this.history[this.memoryAddress].humidity * 100), 4), 762 | numToHex(swap16(this.history[this.memoryAddress].ppm), 4)); 763 | break; 764 | case TYPE_ROOM2: 765 | this.dataStream += Format( 766 | ",15 %s%s%s%s%s%s0054 a80f01", 767 | numToHex(swap32(this.currentEntry), 8), 768 | numToHex(swap32(this.history[this.memoryAddress].time - this.refTime - EPOCH_OFFSET), 8), 769 | this.accessoryType117, 770 | numToHex(swap16(this.history[this.memoryAddress].temp * 100), 4), 771 | numToHex(swap16(this.history[this.memoryAddress].humidity * 100), 4), 772 | numToHex(swap16(this.history[this.memoryAddress].voc), 4)); 773 | break; 774 | case TYPE_DOOR: 775 | case TYPE_MOTION: 776 | case TYPE_SWITCH: 777 | this.dataStream += Format( 778 | ",0b %s%s%s%s", 779 | numToHex(swap32(this.currentEntry), 8), 780 | numToHex(swap32(this.history[this.memoryAddress].time - this.refTime - EPOCH_OFFSET), 8), 781 | this.accessoryType117, 782 | numToHex(this.history[this.memoryAddress].status, 2)); 783 | break; 784 | case TYPE_AQUA: 785 | if (this.history[this.memoryAddress].status == true) 786 | this.dataStream += Format( 787 | ",0d %s%s%s%s 300c", 788 | numToHex(swap32(this.currentEntry), 8), 789 | numToHex(swap32(this.history[this.memoryAddress].time - this.refTime - EPOCH_OFFSET), 8), 790 | this.accessoryType117, 791 | numToHex(this.history[this.memoryAddress].status, 2)); 792 | else 793 | this.dataStream += Format( 794 | ",15 %s%s%s%s%s 00000000 300c", 795 | numToHex(swap32(this.currentEntry), 8), 796 | numToHex(swap32(this.history[this.memoryAddress].time - this.refTime - EPOCH_OFFSET), 8), 797 | this.accessoryType117bis, 798 | numToHex(this.history[this.memoryAddress].status, 2), 799 | numToHex(swap32(this.history[this.memoryAddress].waterAmount), 8)); 800 | break; 801 | case TYPE_THERMO: 802 | this.dataStream += Format( 803 | ",11 %s%s%s%s%s%s 0000", 804 | numToHex(swap32(this.currentEntry), 8), 805 | numToHex(swap32(this.history[this.memoryAddress].time - this.refTime - EPOCH_OFFSET), 8), 806 | this.accessoryType117, 807 | numToHex(swap16(this.history[this.memoryAddress].currentTemp * 100), 4), 808 | numToHex(swap16(this.history[this.memoryAddress].setTemp * 100), 4), 809 | numToHex(this.history[this.memoryAddress].valvePosition, 2)); 810 | break; 811 | case TYPE_CUSTOM: 812 | var result = []; 813 | var bitmask = 0; 814 | var dataStream = Format("%s%s", 815 | numToHex(swap32(this.currentEntry), 8), 816 | numToHex(swap32(this.history[this.memoryAddress].time - this.refTime - EPOCH_OFFSET), 8)); 817 | for (const [key, value] of Object.entries(this.history[this.memoryAddress])) { 818 | switch (key) { 819 | case 'time': 820 | break; 821 | default: 822 | for (var x = 0, iLen = this.signatures.length; x < iLen; x++) { 823 | if (this.signatures[x].entry === key) { 824 | // console.log('**Fakegato-history key', key, this.signatures[x].uuid, value, this.signatures[x].factor); 825 | switch(this.signatures[x].length) { 826 | case 8: 827 | result[x] = Format('%s', numToHex(swap32(value * this.signatures[x].factor), this.signatures[x].length)); 828 | break; 829 | case 4: 830 | result[x] = Format('%s', numToHex(swap16(value * this.signatures[x].factor), this.signatures[x].length)); 831 | break; 832 | case 2: 833 | result[x] = Format('%s', numToHex(value * this.signatures[x].factor, this.signatures[x].length)); 834 | break; 835 | } 836 | bitmask += Math.pow(2, x); 837 | } 838 | } 839 | } 840 | } 841 | var results = dataStream + ' ' + numToHex(bitmask, 2) + ' ' + result.map(a => a).join(' '); 842 | // console.log('**Fakegato-history results', numToHex((results.replace(/[^0-9A-F]/ig, '').length) / 2 + 1) + ' ' + results); 843 | this.dataStream += (' ' + numToHex((results.replace(/[^0-9A-F]/ig, '').length) / 2 + 1) + ' ' + results + ','); 844 | break; 845 | } 846 | } 847 | this.currentEntry++; 848 | this.memoryAddress = entry2address(this.currentEntry); 849 | if (this.currentEntry > this.lastEntry) 850 | break; 851 | } 852 | this.log.debug("**Fakegato-history Data %s: %s", this.accessoryName, this.dataStream); 853 | callback(null, hexToBase64(this.dataStream)); 854 | this.dataStream = ''; 855 | } 856 | else { 857 | this.transfer = false; 858 | callback(null, hexToBase64('00')); 859 | } 860 | } 861 | 862 | 863 | setCurrentS2W1(val, callback) { 864 | callback(null); 865 | this.log.debug("**Fakegato-history Data request %s: %s", this.accessoryName, base64ToHex(val)); 866 | var valHex = base64ToHex(val); 867 | var substring = valHex.substring(4, 12); 868 | var valInt = parseInt(substring, 16); 869 | var address = swap32(valInt); 870 | var hexAddress = address.toString('16'); 871 | 872 | this.log.debug("**Fakegato-history Address requested %s: %s", this.accessoryName, hexAddress); 873 | this.sendHistory(address); 874 | 875 | } 876 | 877 | setCurrentS2W2(val, callback) { 878 | this.log.debug("**Fakegato-history Clock adjust %s: %s", this.accessoryName, base64ToHex(val)); 879 | callback(null); 880 | } 881 | 882 | } 883 | 884 | FakeGatoHistoryService.UUID = 'E863F007-079E-48FF-8F27-9C2605A29F52'; 885 | 886 | return FakeGatoHistory; 887 | }; 888 | -------------------------------------------------------------------------------- /fakegato-storage.js: -------------------------------------------------------------------------------- 1 | /*jshint esversion: 6,node: true,-W041: false */ 2 | 'use strict'; 3 | 4 | const DEBUG = true; 5 | 6 | var fs = require('fs'); 7 | var os = require('os'); 8 | var path = require('path'); 9 | var hostname = os.hostname().split(".")[0]; 10 | 11 | var googleDrive = require('./lib/googleDrive').drive; 12 | 13 | var fileSuffix = '_persist.json'; 14 | 15 | var thisStorage; 16 | 17 | class FakeGatoStorage { 18 | constructor(params) { 19 | if (!params) 20 | params = {}; 21 | 22 | this.writers = []; 23 | 24 | this.log = params.log || {}; 25 | if (!this.log.debug) { 26 | this.log.debug = DEBUG ? console.log : function () { }; 27 | } 28 | thisStorage = this; 29 | this.addingWriter = false; 30 | } 31 | 32 | addWriter(service, params) { 33 | if (!this.addingWriter) { 34 | this.addingWriter = true; 35 | if (!params) 36 | params = {}; 37 | 38 | this.log.debug("** Fakegato-storage AddWriter :", service.accessoryName); 39 | 40 | let newWriter = { 41 | 'service': service, 42 | 'callback': params.callback, 43 | 'storage': params.storage || 'fs', 44 | 'fileName': params.filename || hostname + "_" + service.accessoryName + fileSuffix // Unique filename per homebridge server. Allows test environments on other servers not to break prod. 45 | }; 46 | var onReady = typeof (params.onReady) == 'function' ? params.onReady : function () { }.bind(this); 47 | 48 | switch (newWriter.storage) { 49 | case 'fs': 50 | newWriter.storageHandler = fs; 51 | newWriter.path = params.path || path.join(os.homedir(), '.homebridge'); 52 | this.writers.push(newWriter); 53 | this.addingWriter = false; 54 | onReady(); 55 | break; 56 | case 'googleDrive': 57 | newWriter.path = params.path || 'fakegato'; 58 | newWriter.keyPath = params.keyPath || path.join(os.homedir(), '.homebridge'); 59 | newWriter.storageHandler = new googleDrive({ 60 | keyPath: newWriter.keyPath, callback: function () { 61 | this.addingWriter = false; 62 | onReady(arguments); 63 | }.bind(this), folder: newWriter.path 64 | }); 65 | this.writers.push(newWriter); 66 | break; 67 | /* 68 | case 'memcached' : 69 | 70 | break; 71 | */ 72 | } 73 | } else { 74 | setTimeout(function () { 75 | this.addWriter(service, params); 76 | }.bind(this), 100); 77 | } 78 | } 79 | getWriter(service) { 80 | let findServ = function (element) { 81 | return element.service === service; 82 | }; 83 | return this.writers.find(findServ); 84 | } 85 | _getWriterIndex(service) { 86 | let findServ = function (element) { 87 | return element.service === service; 88 | }; 89 | return this.writers.findIndex(findServ); 90 | } 91 | getWriters() { 92 | return this.writers; 93 | } 94 | delWriter(service) { 95 | let index = this._getWriterIndex(service); 96 | this.writers.splice(index, 1); 97 | } 98 | 99 | write(params) { // must be asynchronous 100 | if (!this.writing) { 101 | this.writing = true; 102 | let writer = this.getWriter(params.service); 103 | let callBack = typeof (params.callback) == 'function' ? params.callback : (typeof (writer.callback) == 'function' ? writer.callback : function () { }); // use parameter callback or writer callback or empty function 104 | switch (writer.storage) { 105 | case 'fs': 106 | this.log.debug("** Fakegato-storage write FS file:", path.join(writer.path, writer.fileName), params.data.substr(1, 80)); 107 | writer.storageHandler.writeFile(path.join(writer.path, writer.fileName), params.data, 'utf8', function () { 108 | this.writing = false; 109 | callBack(arguments); 110 | }.bind(this)); 111 | break; 112 | case 'googleDrive': 113 | this.log.debug("** Fakegato-storage write googleDrive file:", writer.path, writer.fileName, params.data.substr(1, 80)); 114 | writer.storageHandler.writeFile(writer.path, writer.fileName, params.data, function () { 115 | this.writing = false; 116 | callBack(arguments); 117 | }.bind(this)); 118 | break; 119 | /* 120 | case 'memcached' : 121 | 122 | break; 123 | */ 124 | } 125 | } else { 126 | setTimeout(function () { // retry in 100ms 127 | this.write(params); 128 | }.bind(this), 100); 129 | } 130 | } 131 | read(params) { 132 | let writer = this.getWriter(params.service); 133 | let callBack = typeof (params.callback) == 'function' ? params.callback : (typeof (writer.callback) == 'function' ? writer.callback : function () { }); // use parameter callback or writer callback or empty function 134 | switch (writer.storage) { 135 | case 'fs': 136 | this.log.debug("** Fakegato-storage read FS file:", path.join(writer.path, writer.fileName)); 137 | writer.storageHandler.readFile(path.join(writer.path, writer.fileName), 'utf8', callBack); 138 | break; 139 | case 'googleDrive': 140 | this.log.debug("** Fakegato-storage read googleDrive file: %s/%s", writer.path, writer.fileName); 141 | writer.storageHandler.readFile(writer.path, writer.fileName, callBack); 142 | break; 143 | /* 144 | case 'memcached' : 145 | 146 | break; 147 | */ 148 | } 149 | } 150 | remove(params) { 151 | let writer = this.getWriter(params.service); 152 | let callBack = typeof (params.callback) == 'function' ? params.callback : (typeof (writer.callback) == 'function' ? writer.callback : function () { }); // use parameter callback or writer callback or empty function 153 | switch (writer.storage) { 154 | case 'fs': 155 | this.log.debug("** Fakegato-storage delete FS file:", path.join(writer.path, writer.fileName)); 156 | writer.storageHandler.unlink(path.join(writer.path, writer.fileName), callBack); 157 | break; 158 | case 'googleDrive': 159 | this.log.debug("** Fakegato-storage delete googleDrive file:", writer.path, writer.fileName); 160 | writer.storageHandler.deleteFile(writer.path, writer.fileName, callBack); 161 | break; 162 | /* 163 | case 'memcached' : 164 | 165 | break; 166 | */ 167 | } 168 | } 169 | } 170 | 171 | module.exports = { 172 | FakeGatoStorage: FakeGatoStorage 173 | }; 174 | -------------------------------------------------------------------------------- /fakegato-timer.js: -------------------------------------------------------------------------------- 1 | /*jshint esversion: 6,node: true,-W041: false */ 2 | 'use strict'; 3 | 4 | const DEBUG = true; 5 | 6 | class FakeGatoTimer { 7 | constructor(params) { 8 | if (!params) 9 | params = {}; 10 | this.subscribedServices = []; 11 | this.minutes = params.minutes || 10; 12 | 13 | this.intervalID = null; 14 | this.running = false; 15 | this.log = params.log || {}; 16 | if (!this.log.debug) { 17 | this.log.debug = DEBUG ? console.log : function () { }; 18 | } 19 | } 20 | 21 | // Subscription management 22 | subscribe(service, callback) { 23 | this.log.debug("** Fakegato-timer Subscription :", service.accessoryName); 24 | let newService = { 25 | 'service': service, 26 | 'callback': callback, 27 | 'backLog': [], 28 | 'previousBackLog': [], 29 | 'previousAvrg': {} 30 | }; 31 | 32 | this.subscribedServices.push(newService); 33 | } 34 | getSubscriber(service) { 35 | let findServ = function (element) { 36 | return element.service === service; 37 | }; 38 | return this.subscribedServices.find(findServ); 39 | } 40 | _getSubscriberIndex(service) { 41 | let findServ = function (element) { 42 | return element.service === service; 43 | }; 44 | return this.subscribedServices.findIndex(findServ); 45 | } 46 | getSubscribers() { 47 | return this.subscribedServices; 48 | } 49 | unsubscribe(service) { 50 | let index = this._getSubscriberIndex(service); 51 | this.subscribedServices.splice(index, 1); 52 | if (this.subscribedServices.length === 0 && this.running) 53 | this.stop(); 54 | } 55 | 56 | // Timer management 57 | start() { 58 | this.log.debug("**Start Global Fakegato-Timer - " + this.minutes + "min**"); 59 | if (this.running) 60 | this.stop(); 61 | this.running = true; 62 | this.intervalID = setInterval(this.executeCallbacks.bind(this), this.minutes * 60 * 1000); 63 | } 64 | stop() { 65 | this.log.debug("**Stop Global Fakegato-Timer****"); 66 | clearInterval(this.intervalID); 67 | this.running = false; 68 | this.intervalID = null; 69 | } 70 | 71 | // Data management 72 | executeCallbacks() { 73 | this.log.debug("**Fakegato-timer: executeCallbacks**"); 74 | if (this.subscribedServices.length !== 0) { 75 | for (let s in this.subscribedServices) { 76 | if (this.subscribedServices.hasOwnProperty(s)) { 77 | 78 | let service = this.subscribedServices[s]; 79 | if (typeof (service.callback) == 'function') { 80 | service.previousAvrg = service.callback({ 81 | 'backLog': service.backLog, 82 | 'previousAvrg': service.previousAvrg, 83 | 'timer': this, 84 | 'immediate': false 85 | }); 86 | } 87 | } 88 | } 89 | } 90 | } 91 | executeImmediateCallback(service) { 92 | this.log.debug("**Fakegato-timer: executeImmediateCallback**"); 93 | 94 | if (typeof (service.callback) == 'function' && service.backLog.length) 95 | service.callback({ 96 | 'backLog': service.backLog, 97 | 'timer': this, 98 | 'immediate': true 99 | }); 100 | } 101 | addData(params) { 102 | let data = params.entry; 103 | let service = params.service; 104 | let immediateCallback = params.immediateCallback || false; 105 | 106 | this.log.debug("**Fakegato-timer: addData ", service.accessoryName, data, " immediate: ", immediateCallback); 107 | 108 | if (immediateCallback) // door or motion -> replace 109 | this.getSubscriber(service).backLog[0] = data; 110 | else 111 | this.getSubscriber(service).backLog.push(data); 112 | 113 | if (immediateCallback) { 114 | //setTimeout(this.executeImmediateCallback.bind(this), 0,service); 115 | this.executeImmediateCallback(this.getSubscriber(service)); 116 | } 117 | 118 | if (this.running === false) 119 | this.start(); 120 | } 121 | emptyData(service) { 122 | this.log.debug("**Fakegato-timer: emptyData **", service.accessoryName); 123 | let source = this.getSubscriber(service); 124 | 125 | if (source.backLog.length) source.previousBackLog = source.backLog; 126 | source.backLog = []; 127 | } 128 | 129 | } 130 | 131 | module.exports = { 132 | FakeGatoTimer: FakeGatoTimer 133 | }; 134 | -------------------------------------------------------------------------------- /lib/googleDrive.js: -------------------------------------------------------------------------------- 1 | /* jshint esversion: 6,node: true,-W041: false */ 2 | 'use strict'; 3 | var debug = require('debug')('FakeGatoStorageDrive'); 4 | var fs = require('fs'); 5 | var readline = require('readline'); 6 | 7 | const {google} = require('googleapis'); 8 | var path = require('path'); 9 | var os = require('os'); 10 | 11 | module.exports = { 12 | drive: drive 13 | }; 14 | 15 | // If modifying these scopes, delete your previously saved credentials 16 | // at ~/.credentials/drive-nodejs-quickstart.json 17 | var SCOPES = ['https://www.googleapis.com/auth/drive']; 18 | var TOKEN_DIR = path.join((process.env.HOME || process.env.HOMEPATH || 19 | process.env.USERPROFILE || os.homedir()), '.homebridge'); 20 | var TOKEN_PATH = path.join(TOKEN_DIR, 'drive-nodejs-quickstart.json'); 21 | var SECRET_PATH = path.join(TOKEN_DIR, 'client_secret.json'); 22 | var auth; 23 | 24 | function drive(params) { 25 | if (params && params.keyPath) { 26 | TOKEN_DIR = params.keyPath; 27 | TOKEN_PATH = path.join(TOKEN_DIR, 'drive-nodejs-quickstart.json'); 28 | SECRET_PATH = path.join(TOKEN_DIR, 'client_secret.json'); 29 | } 30 | 31 | // Load client secrets from a local file. 32 | fs.readFile(SECRET_PATH, function processClientSecrets(err, content) { 33 | if (err) { 34 | console.log('Error loading client secret file, please follow the instructions in the README!!!', err); 35 | return; 36 | } 37 | // Authorize a client with the loaded credentials, then call the 38 | // Drive API. 39 | authorize(JSON.parse(content), function(authenticated) { 40 | auth = authenticated; 41 | debug("Authenticated", content, params); 42 | if (params) { 43 | if (params.folder) { 44 | getFolder(params.folder, params.callback); // create if not exists (always callback) 45 | } else { 46 | if (typeof (params.callback) === 'function') { 47 | params.callback(); 48 | } 49 | } 50 | } 51 | }); 52 | }); 53 | } 54 | 55 | drive.prototype.writeFile = function(folder, name, data, cb) { 56 | // get folder ID 57 | if (auth) { 58 | if (this.updating !== true) { 59 | this.updating = true; 60 | // debug("getFolder",folder); 61 | getFolder(folder, function(err, folder) { 62 | // debug("upload",name); 63 | if (err) { 64 | debug("writeFile - Can't get folder", err); 65 | this.updating = false; 66 | cb(); 67 | } else { 68 | myUploadFile(folder, name, data, function() { 69 | this.updating = false; 70 | cb(arguments); 71 | }.bind(this)); 72 | } 73 | }.bind(this)); 74 | } else { 75 | setTimeout(function() { 76 | this.writeFile(folder, name, data, cb); 77 | }.bind(this), 100); 78 | } 79 | } else { 80 | debug("NO AUTH YET (Not normal)"); 81 | setTimeout(function() { 82 | this.writeFile(folder, name, data, cb); 83 | }.bind(this), 100); 84 | } 85 | }; 86 | 87 | drive.prototype.readFile = function(folder, name, cb) { 88 | if (auth) { 89 | // debug("getFolder",folder); 90 | getFolder(folder, function(err, folder) { 91 | if (err) { 92 | debug("getFolder retry %s/%s", folder, name); 93 | setTimeout(function() { 94 | this.readFile(folder, name, cb); 95 | }.bind(this), 100); 96 | } else { 97 | debug("download %s/%s", folder, name); 98 | myDownloadFile(folder, name, cb); 99 | } 100 | }.bind(this)); 101 | } else { 102 | debug("NO AUTH YET (Not normal)"); 103 | setTimeout(function() { 104 | this.readFile(folder, name, cb); 105 | }.bind(this), 100); 106 | } 107 | }; 108 | 109 | drive.prototype.deleteFile = function(folder, name, cb) { 110 | if (auth) { 111 | // debug("getFolder",folder); 112 | getFolder(folder, function(err, folder) { 113 | debug("delete", name); 114 | myDeleteFile(folder, name, cb); 115 | }); 116 | } else { 117 | debug("NO AUTH YET (Not normal)"); 118 | setTimeout(function() { 119 | this.deleteFile(folder, name, cb); 120 | }.bind(this), 100); 121 | } 122 | }; 123 | 124 | function getFolder(folder, cb) { 125 | var drive = google.drive('v3'); 126 | // debug("getFolder",folder); 127 | drive.files.list({ 128 | q: "mimeType='application/vnd.google-apps.folder' and name = '" + folder + "' and trashed = false", 129 | fields: 'nextPageToken, files(id, name)', 130 | spaces: 'drive', 131 | auth: auth 132 | }, function(err, res) { 133 | if (err) { 134 | debug("getFolder - err", err); 135 | cb(err, folder); 136 | } else { 137 | if (res.data.files.length > 0) { 138 | if (res.data.files.length > 1) { 139 | debug("Multiple folders with same name, taking the first one", folder, 'in', res.data.files); 140 | } 141 | // debug('Found Folder: ', res.files[0].name, res.files[0].id); 142 | cb(null, res.data.files[0].id); 143 | } else { 144 | var fileMetadata = { 145 | 'name': folder, 146 | 'mimeType': 'application/vnd.google-apps.folder' 147 | }; 148 | drive.files.create({ 149 | resource: fileMetadata, 150 | fields: 'id', 151 | auth: auth 152 | }, function(err, file) { 153 | if (err) { 154 | // Handle error 155 | debug(err); 156 | } else { 157 | debug("Created Folder", file.id); 158 | cb(null, file.id); 159 | } 160 | }); 161 | } 162 | } 163 | }); 164 | } 165 | 166 | function getFileID(folder, name, cb) { 167 | var drive = google.drive('v3'); 168 | // debug("GET FILE ID : %s/%s",folder,name); 169 | drive.files.list({ 170 | q: "name = '" + name + "' and trashed = false and '" + folder + "' in parents", 171 | fields: 'files(id, name)', 172 | spaces: 'drive', 173 | // parents: [folder], 174 | auth: auth 175 | }, function(err, result) { 176 | // debug("GET FILE ID result",result,err); 177 | cb(err, result); 178 | }); 179 | } 180 | 181 | function myUploadFile(folder, name, data, cb) { 182 | var drive = google.drive('v3'); 183 | // debug("upload File %s\%s", folder, name); 184 | var fileMetadata = { 185 | 'name': name, 186 | parents: [folder] 187 | }; 188 | var media = { 189 | mimeType: 'application/json', 190 | body: JSON.stringify(data) 191 | }; 192 | getFileID(folder, name, function(err, result) { 193 | // debug("fileID for %s/%s is :",folder,name,result,err); 194 | if (result && result.data.files && result.data.files.length > 0) { 195 | drive.files.update({ 196 | fileId: result.data.files[0].id, 197 | media: media, 198 | auth: auth 199 | }, function(err, file) { 200 | if (err) { 201 | debug('FILEUPDATE :', err); 202 | } else { 203 | debug('myUploadFile - update success', name); 204 | } 205 | cb(err, file); 206 | }); 207 | } else { 208 | debug("no file found, creating", name, fileMetadata, media); 209 | drive.files.create({ 210 | resource: fileMetadata, 211 | media: media, 212 | fields: 'id', 213 | auth: auth 214 | }, function(err, file) { 215 | if (err) { 216 | debug('FILECREATE :', file, err); 217 | } else { 218 | debug('myUploadFile - create success', name); 219 | } 220 | cb(err, file); 221 | }); 222 | } 223 | }); 224 | } 225 | 226 | function myDownloadFile(folder, name, cb) { 227 | var drive = google.drive('v3'); 228 | 229 | debug("download file", folder, name); 230 | 231 | getFileID(folder, name, function(err, result) { 232 | if (result && result.data.files && result.data.files.length) { 233 | if (result.data.files.length > 1) { 234 | debug("Multiple files with same name, taking the first one", name, 'in', result.data.files); 235 | } 236 | drive.files.get({ 237 | fileId: result.data.files[0].id, 238 | alt: 'media', 239 | auth: auth 240 | }, function(err, success) { 241 | if (err) debug("ERROR downloading", err); 242 | else debug("SUCCESS downloading", name); 243 | cb(err, success.data); 244 | }); 245 | } else { 246 | debug("no file found", name); 247 | cb(new Error("File not found"), false); 248 | } 249 | }); 250 | } 251 | 252 | function myDeleteFile(folder, name, cb) { 253 | var drive = google.drive('v3'); 254 | 255 | debug("delete file", folder, name); 256 | 257 | getFileID(folder, name, function(err, result) { 258 | if (result && result.data.files && result.data.files.length) { 259 | if (result.data.files.length > 1) { 260 | debug("Multiple files with same name, taking the first one", name, 'in', result.data.files); 261 | } 262 | drive.files.delete({ 263 | fileId: result.data.files[0].id, 264 | auth: auth 265 | }, function(err, success) { 266 | if (err) debug("ERROR deleting", err); 267 | else debug("SUCCESS deleting", success); 268 | cb(err, success); 269 | }); 270 | } else { 271 | debug("no file found", name); 272 | cb(null, false); 273 | } 274 | }); 275 | } 276 | 277 | // This is all from the Google Drive Quickstart 278 | 279 | /** 280 | * Create an OAuth2 client with the given credentials, and then execute the 281 | * given callback function. 282 | * 283 | * @param {Object} credentials The authorization client credentials. 284 | * @param {function} callback The callback to call with the authorized client. 285 | */ 286 | function authorize(credentials, callback) { 287 | const {client_secret, client_id, redirect_uris} = credentials.installed; 288 | const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); 289 | 290 | // Check if we have previously stored a token. 291 | fs.readFile(TOKEN_PATH, function(err, token) { 292 | if (err) { 293 | getNewToken(oAuth2Client, callback); 294 | } else { 295 | oAuth2Client.credentials = JSON.parse(token); 296 | callback(oAuth2Client); 297 | } 298 | }); 299 | } 300 | 301 | /** 302 | * Get and store new token after prompting for user authorization, and then 303 | * execute the given callback with the authorized OAuth2 client. 304 | * 305 | * @param {google.auth.OAuth2} oauth2Client The OAuth2 client to get token for. 306 | * @param {getEventsCallback} callback The callback to call with the authorized 307 | * client. 308 | */ 309 | function getNewToken(oAuth2Client, callback) { 310 | var authUrl = oAuth2Client.generateAuthUrl({ 311 | access_type: 'offline', 312 | scope: SCOPES 313 | }); 314 | console.log('Authorize this app by visiting this url: ', authUrl); 315 | var rl = readline.createInterface({ 316 | input: process.stdin, 317 | output: process.stdout 318 | }); 319 | rl.question('Enter the code from that page here: ', function(code) { 320 | rl.close(); 321 | oAuth2Client.getToken(code, function(err, token) { 322 | if (err) { 323 | console.log('Error while trying to retrieve access token', err); 324 | return; 325 | } 326 | oAuth2Client.credentials = token; 327 | storeToken(token); 328 | callback(oAuth2Client); 329 | }); 330 | }); 331 | } 332 | 333 | /** 334 | * Store token to disk be used in later program executions. 335 | * 336 | * @param {Object} token The token to store to disk. 337 | */ 338 | function storeToken(token) { 339 | try { 340 | fs.mkdirSync(TOKEN_DIR); 341 | } catch (err) { 342 | if (err.code !== 'EEXIST') { 343 | throw err; 344 | } 345 | } 346 | fs.writeFile(TOKEN_PATH, JSON.stringify(token), (err) => { 347 | if (err) throw err; 348 | console.log('Token stored to ' + TOKEN_PATH); 349 | }); 350 | } 351 | 352 | /** 353 | * Lists the names and IDs of up to 10 files. 354 | * 355 | * @param {google.auth.OAuth2} auth An authorized OAuth2 client. 356 | */ 357 | function listFiles(auth) { 358 | var drive = google.drive('v3'); 359 | drive.files.list({ 360 | auth: auth, 361 | pageSize: 30, 362 | fields: "nextPageToken, files(id, name)" 363 | }, function(err, response) { 364 | if (err) { 365 | console.log('The API returned an error: ' + err); 366 | return; 367 | } 368 | var files = response.files; 369 | if (files.length === 0) { 370 | debug('No files found.'); 371 | } else { 372 | debug('Files:'); 373 | for (var i = 0; i < files.length; i++) { 374 | var file = files[i]; 375 | debug('%s (%s)', JSON.stringify(file, null, 2), file.name, file.id); 376 | } 377 | } 378 | }); 379 | } 380 | 381 | function uploadFile(auth) { 382 | var drive = google.drive('v3'); 383 | 384 | var fetchPage = function(pageToken, pageFn, callback) { 385 | drive.files.list({ 386 | q: "mimeType='application/vnd.google-apps.folder' and name = 'Camera Pictures'", 387 | fields: 'nextPageToken, files(id, name)', 388 | spaces: 'drive', 389 | pageToken: pageToken, 390 | auth: auth 391 | }, function(err, res) { 392 | if (err) { 393 | callback(err); 394 | } else { 395 | res.files.forEach(function(file) { 396 | debug('Found file: ', file.name, file.id); 397 | }); 398 | if (res.nextPageToken) { 399 | debug("Page token", res.nextPageToken); 400 | pageFn(res.nextPageToken, pageFn, callback); 401 | } else { 402 | callback(); 403 | } 404 | } 405 | }); 406 | }; 407 | fetchPage(null, fetchPage, function(err) { 408 | if (err) { 409 | // Handle error 410 | console.log(err); 411 | } else { 412 | // All pages fetched 413 | } 414 | }); 415 | 416 | var fileMetadata = { 417 | 'name': 'Camera Pictures', 418 | 'mimeType': 'application/vnd.google-apps.folder' 419 | }; 420 | drive.files.create({ 421 | resource: fileMetadata, 422 | fields: 'id', 423 | auth: auth 424 | }, function(err, file) { 425 | if (err) { 426 | // Handle error 427 | console.log(err); 428 | } else { 429 | debug('Folder Id: ', file.id); 430 | 431 | var fileMetadata = { 432 | 'name': 'photo.jpg', 433 | parents: [file.id] 434 | }; 435 | var media = { 436 | mimeType: 'image/jpeg', 437 | body: fs.createReadStream('photo.jpg') 438 | }; 439 | 440 | drive.files.create({ 441 | resource: fileMetadata, 442 | media: media, 443 | fields: 'id', 444 | auth: auth 445 | }, function(err, file) { 446 | if (err) { 447 | // Handle error 448 | console.log(err); 449 | } else { 450 | debug('File Id: ', file.id); 451 | } 452 | }); 453 | } 454 | }); 455 | } 456 | -------------------------------------------------------------------------------- /lib/uuid.js: -------------------------------------------------------------------------------- 1 | // https://github.com/homebridge/HAP-NodeJS/blob/master/src/lib/util/uuid.ts 2 | 3 | const VALID_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 4 | 5 | function isValid(UUID) { 6 | return VALID_UUID_REGEX.test(UUID); 7 | } 8 | const VALID_SHORT_REGEX = /^[0-9a-f]{1,8}$/i; 9 | 10 | function toLongFormUUID(uuid, base = '-0000-1000-8000-0026BB765291') { 11 | if (isValid(uuid)) return uuid.toUpperCase(); 12 | if (!VALID_SHORT_REGEX.test(uuid)) throw new TypeError('uuid was not a valid UUID or short form UUID'); 13 | if (!isValid('00000000' + base)) throw new TypeError('base was not a valid base UUID'); 14 | 15 | return (('00000000' + uuid).substr(-8) + base).toUpperCase(); 16 | } 17 | 18 | function toShortFormUUID(uuid, base = '-0000-1000-8000-0026BB765291') { 19 | uuid = toLongFormUUID(uuid, base); 20 | return (uuid.substr(0, 8)); 21 | } 22 | 23 | exports.isValid = isValid; 24 | exports.toLongFormUUID = toLongFormUUID; 25 | exports.toShortFormUUID = toShortFormUUID; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fakegato-history", 3 | "version": "0.6.7", 4 | "description": "Module emulating Elgato Eve history for homebridge plugins", 5 | "main": "fakegato-history.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/simont77/fakegato-history.git" 9 | }, 10 | "engines": { 11 | "node": ">=4.3.2", 12 | "homebridge": ">=0.4.0" 13 | }, 14 | "dependencies": { 15 | "googleapis": ">39.1.0", 16 | "debug": "^2.2.0" 17 | }, 18 | "author": "simont77", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/simont77/fakegato-history/issues" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /quickstartGoogleDrive.js: -------------------------------------------------------------------------------- 1 | /* jshint esversion: 6,node: true,-W041: false */ 2 | const fs = require('fs').promises; 3 | const path = require('path'); 4 | const process = require('process'); 5 | const {authenticate} = require('@google-cloud/local-auth'); 6 | const {google} = require('googleapis'); 7 | 8 | // If modifying these scopes, delete token.json. 9 | const SCOPES = ['https://www.googleapis.com/auth/drive.file']; 10 | // The file token.json stores the user's access and refresh tokens, and is 11 | // created automatically when the authorization flow completes for the first 12 | // time. 13 | const TOKEN_PATH = path.join(process.cwd(), 'drive-nodejs-quickstart.json'); 14 | const CREDENTIALS_PATH = path.join(process.cwd(), 'client_secret.json'); 15 | 16 | /** 17 | * Reads previously authorized credentials from the save file. 18 | * 19 | * @return {Promise} 20 | */ 21 | async function loadSavedCredentialsIfExist() { 22 | try { 23 | const content = await fs.readFile(TOKEN_PATH); 24 | const credentials = JSON.parse(content); 25 | return google.auth.fromJSON(credentials); 26 | } catch (err) { 27 | return null; 28 | } 29 | } 30 | 31 | /** 32 | * Serializes credentials to a file comptible with GoogleAUth.fromJSON. 33 | * 34 | * @param {OAuth2Client} client 35 | * @return {Promise} 36 | */ 37 | async function saveCredentials(client) { 38 | const content = await fs.readFile(CREDENTIALS_PATH); 39 | const keys = JSON.parse(content); 40 | const key = keys.installed || keys.web; 41 | const payload = JSON.stringify({ 42 | type: 'authorized_user', 43 | client_id: key.client_id, 44 | client_secret: key.client_secret, 45 | refresh_token: client.credentials.refresh_token, 46 | }); 47 | await fs.writeFile(TOKEN_PATH, payload); 48 | } 49 | 50 | /** 51 | * Load or request or authorization to call APIs. 52 | * 53 | */ 54 | async function authorize() { 55 | let client = await loadSavedCredentialsIfExist(); 56 | if (client) { 57 | return client; 58 | } 59 | client = await authenticate({ 60 | scopes: SCOPES, 61 | keyfilePath: CREDENTIALS_PATH, 62 | }); 63 | if (client.credentials) { 64 | await saveCredentials(client); 65 | } 66 | return client; 67 | } 68 | 69 | /** 70 | * Lists the names and IDs of up to 10 files. 71 | * @param {OAuth2Client} authClient An authorized OAuth2 client. 72 | */ 73 | async function listFiles(authClient) { 74 | const drive = google.drive({version: 'v3', auth: authClient}); 75 | const res = await drive.files.list({ 76 | pageSize: 10, 77 | fields: 'nextPageToken, files(id, name)', 78 | }); 79 | const files = res.data.files; 80 | if (files.length === 0) { 81 | console.log('No files found.'); 82 | return; 83 | } 84 | 85 | console.log('Files:'); 86 | files.map((file) => { 87 | console.log(`${file.name} (${file.id})`); 88 | }); 89 | } 90 | 91 | authorize().then(listFiles).catch(console.error); --------------------------------------------------------------------------------