├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SERVICE.md ├── blinker.go ├── calblink.go ├── calendar.go ├── config.go ├── network.go └── service.go /.gitignore: -------------------------------------------------------------------------------- 1 | go.mod 2 | go.sum 3 | calblink 4 | client_secret.json 5 | conf.json 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blink(1) for Google Calendar (calblink) 2 | 3 | ## What is this? 4 | 5 | Calblink is a small program to watch your Google Calendar and set a blink(1) USB 6 | LED to change colors based on your next meeting. The colors it will use are: 7 | 8 | * Off: nothing on your calendar for the next 60 minutes 9 | * Green: 30 to 60 minutes 10 | * Yellow: 10 to 30 minutes 11 | * Red: 5 to 10 minutes 12 | * Flashing red: 0 to 5 minutes, flashing faster for the last 2 minutes 13 | * Flashing blue and red: First minute of the meeting 14 | * Blue: In meeting 15 | * Flashing magenta: Unable to connect to Calendar server. This is to prevent 16 | the case where calblink silently fails and leaves you unaware that it has 17 | failed. 18 | 19 | ## What do I need use it? 20 | 21 | To use calblink, you need the following: 22 | 23 | 1. A blink(1) from [ThingM](http://blink1.thingm.com/) - calblink supports 24 | mk1, mk2, and mk3 blink(1). 25 | 1. A place to connect the blink(1) where you can see it. 26 | 2. The latest version of [Go](https://golang.org/). 27 | 3. The calblink code, found in this directory. 28 | 4. libusb-compat. The [go-blink1](https://github.com/kazrakcom/go-blink1) page has 29 | details. 30 | 5. A directory to run this in. 31 | 6. A few go packages, which we'll install later in the Setup section. 32 | 7. A Google Calendar account. 33 | 8. A Google Calendar OAuth 2 client ID. (We'll discuss getting one in the Setup 34 | section as well.) 35 | 36 | ## How do I set this up? 37 | 38 | 1. Install Go, and plug your blink(1) in somewhere that you can see it. 39 | 2. Bring up a command-line window, and create the directory you want to run 40 | this in. 41 | 3. Put all .go files in this repo into the directory you just created. 42 | 4. Install libusb-compat, if needed. 43 | 5. Create your module file: 44 | ``` 45 | go mod init calblink 46 | go mod tidy 47 | ``` 48 | 6. If you already have a `go.mod` and `go.sum`, you may need to update it 49 | before compiling. 50 | ``` 51 | go get -u 52 | go mod tidy 53 | ``` 54 | 55 | 7. Get an OAuth 2 ID as described in step 1 of the [Google Calendar 56 | Quickstart](https://developers.google.com/google-apps/calendar/quickstart/go). 57 | + NOTE: If you are using a consumer Gmail account (as opposed to a Workspace 58 | account) there are some additional steps required here. If you are using a 59 | Workspace account, these steps can be skipped. 60 | 1. The Quickstart will recommend creating an Internal app, but that is only 61 | available for Workspace accounts. Instead, create an External app. Enter 62 | the necessary information on the first screen, then select 'Save and 63 | Continue'. 64 | 2. The next screen should include a button to 'Add or Remove Scopes'. Select 65 | that and add the scope '.../auth/calendar.readonly', listed under 'Google 66 | Calendar API'. Once that is done, select 'Save and Continue'. 67 | 3. Select '+ Add Users' to add a test user to the app. Give the email address 68 | you intend to log in from. Select 'Save and Continue' once again. 69 | 70 | 8. Put the client\_secret.json file in your GOPATH directory. 71 | 72 | 9. Make sure the client\_secret.json file is secure by changing its permissions 73 | to only allow the user to read it: 74 | 75 | chmod 600 client_secret.json 76 | 77 | 10. Build the calblink program as appropriate for your environment: 78 | * For a Linux environment or another that doesn't use Homebrew: 79 | 80 | go build 81 | * For a default Homebrew install on an Intel-based Mac: 82 | 83 | CGO_LDFLAGS="-L/usr/local/lib" CGO_CFLAGS="-I/usr/local/include" go build 84 | * For a default Homebrew install on an ARM-based Mac: 85 | 86 | CGO_LDFLAGS="-L/opt/homebrew/lib" CGO_CFLAGS="-I/opt/homebrew/include" go build 87 | * For a customized Homebrew install, modify the above to match your configuration. 88 | 89 | 11. Run the calblink program: 90 | 91 | ./calblink 92 | 93 | 12. It will request that you go to a URL. On macOS, it will also request that you allow 94 | the program to receive network requests; you should allow this. You should access 95 | this URL from the account you want to read the calendar of. 96 | 97 | 13. That's it! It should just run now, and set your blink(1) to change color 98 | appropriately. To quit out of it, hit Ctrl-C in the window you ran it in. 99 | (It will turn the blink(1) off automatically.) It will output a . into the 100 | terminal window every time it checks the server and sets the LED. 101 | 102 | 14. Optionally, set up a config file, as below. 103 | 104 | 15. Once everything is working, you can consider enabling [service mode](SERVICE.md) to 105 | have it run automatically in the background. 106 | 107 | ## What are the configuration options? 108 | 109 | First off, run it with the --help option to see what the command-line options 110 | are. Useful, perhaps, but maybe not what you want to use every time you run it. 111 | 112 | calblink will look for a file named (by default) conf.json for its configuration 113 | options. conf.json includes several useful options you can set: 114 | 115 | * excludes - a list of event titles which it will ignore. If you like blocking 116 | out time with "Make Time" or similar, you can add these names to the 117 | 'excludes' array. 118 | * excludePrefixes - a list of event title prefixes which it will ignore. This is useful 119 | for blocks that start consistently but may not end consistently, such as "On call, 120 | secondary is PERSON". 121 | * startTime - an HH:MM time (24-hour clock) which calblink won't turn on 122 | before. Because you might not want it turning on at 4am. 123 | * endTime - an HH:MM time (24-hour clock) which it won't turn on after. 124 | * skipDays - a list of days of the week that it should skip. A blink(1) in 125 | the offices doesn't need to run on Saturday/Sunday, after all, and if you 126 | WFH every Friday, why distract your coworkers? 127 | * pollInterval - how often (in seconds) it should check with Calendar for an 128 | update. Default is 30 seconds. Don't push this too frequent or you'll run 129 | out of API quota. 130 | * calendar - which calendar to watch (defaults to primary). This is the email 131 | address of the calendar - either the calendar's owner, or the ID in its 132 | details page for a secondary calendar. "primary" is a magic string that 133 | means "the main calendar of the account whose auth token I'm using". 134 | * calendars - array of calendars to watch. This will override calendar if it is set. 135 | All calendars listed will be watched for events. Note that the signed-in account 136 | must have access to all calendars, and that if you query too many calendars you 137 | may run into issues with the free query quota for Google Calendar, especially if 138 | you are using your oauth key in multiple locations. 139 | * responseState - which response states are marked as being valid for a 140 | meeting. Can be set to "all", in which case any item on your calendar will 141 | light up; "accepted", in which case only items marked as 'accepted' on 142 | calendar will light up; or "notRejected", in which case items that you have 143 | rejected will not light up. Default is "notRejected". 144 | * deviceFailureRetries - how many times to retry accessing the blink(1) before 145 | failing out and terminating the program. Default is 10. 146 | * showDots - whether to show a dot (or similar mark) after every poll interval 147 | to show that the program is running. Default is true. Symbols have the 148 | following meanings: 149 | * . - working normally 150 | * , - unable to talk to the calendar server. After 3 consecutive failures, 151 | the blink(1) will be set to flashing magenta to indicate that it is no 152 | longer current. 153 | * < - sleeping because we've reached endTime for today. 154 | * \> - sleeping because we haven't reached startTime yet today. 155 | * ~ - sleeping because it's a skip day 156 | * X - device failure. 157 | * multiEvent - if true, calblink will check the next two events, and if they are 158 | both in the time frame to show, it will show both. 159 | * priorityFlashSide - if 0 (the default), which side of the blink(1) is flashing 160 | will not be adjusted. If set to 1, then flashing will be prioritized on LED 1; 161 | if 2, flashing will be prioritized on LED2. Any other values are undefined. 162 | * workingLocations - a list of working locations to filter results by. If all 163 | calendars with working locations set have locations that are not in the list of 164 | locations, no events will be shown. Handling of multiple calendars with working 165 | locations set may be suboptimal - if one calendar is set to homeOffice and another 166 | is set to an office location, both will be valid for all events on either calendar. 167 | Values should be in the following formats: 168 | * 'home' to indicate WFH 169 | * 'office:NAME' to match an office location called NAME. 170 | * 'custom:NAME' to match a custom location called NAME. 171 | 172 | An example file: 173 | 174 | ```json 175 | { 176 | "excludes": ["Commute"], 177 | "skipDays": ["Saturday", "Sunday"], 178 | "startTime": "08:45", 179 | "endTime": "18:00", 180 | "pollInterval": 60, 181 | "calendars": ["primary", "username@example.com"], 182 | "responseState": "accepted", 183 | "multiEvent": "true", 184 | "priorityFlashSide": 1, 185 | "workingLocations": ["home"] 186 | } 187 | ``` 188 | 189 | (Yes, the curly braces are required. Sorry. It's a JSON dictionary.) 190 | 191 | 192 | 193 | 194 | ### New Requirements 195 | In addition to the existing setup, please ensure the following requirements are met: 196 | 197 | 1. **File Permission Check**: The client secret file (`client_secret.json`) must have restricted permissions to ensure sensitive credentials are protected. 198 | 199 | ### Why This Change is Necessary 200 | Ensuring that sensitive files, such as client secret files containing authentication credentials, are accessible only by authorized users is crucial for preventing unauthorized access and potential security breaches. By implementing a file permission check, we mitigate the risk of exposing sensitive information to unauthorized users or processes. 201 | 202 | ### Configuration Notes: 203 | To comply with the new security measure and meet the new requirements, please follow these configuration steps: 204 | 205 | 1. **File Permission Requirement**: 206 | - Ensure that the client secret file (`client_secret.json`) is only readable by the owner. This can be achieved by setting appropriate file permissions using the `chmod` command. For example: 207 | ``` 208 | chmod 600 client_secret.json 209 | ``` 210 | This command restricts read and write permissions to the owner only, ensuring that sensitive credentials are protected from unauthorized access. 211 | 212 | ## Known Issues 213 | 214 | * Occasionally the shutdown is not as clean as it should be. 215 | * Something seems to cause an occasional crash. 216 | * If the blink(1) becomes disconnected, sometimes the program crashes instead of failing 217 | gracefully. 218 | 219 | ## Troubleshooting 220 | 221 | * If the blink(1) is flashing magenta, this means it was unable to connect to 222 | or authenticate to the Google Calendar server. If your network is okay, your 223 | auth token may have expired. Remove ~/.credentials/calendar-blink1.json and 224 | reconnect the app to your account. 225 | * If an error message about "no required module provides package..." comes up after 226 | updating calblink, run the following to update all needed modules: 227 | ``` 228 | go get -u 229 | go mod tidy 230 | ``` 231 | * If attempting to install the blink1 go library or run calblink.go on OSX 232 | gives an error about "'usb.h' file not found", make sure that C_INCLUDE_PATH 233 | and LIBRARY_PATH are set appropriately. 234 | * Sending a SIGQUIT will turn on debug mode while the app is running. By 235 | default on Unix-based systems, this is sent by hitting Ctrl-\\ (backslash). 236 | There is currently no way to turn debug mode off once it is set. 237 | * If attempting to run gives an error about 'invalid\_grant' and 'Bad Request', confirm 238 | that the OAuth scopes and test users are specified properly if you are using an 239 | External app (as you must when using a non-Workspace account). In this case, check 240 | the note under installation step 7. 241 | 242 | ## Legal 243 | 244 | * Calblink is not an official Google product. 245 | * Calblink is licensed under the Apache 2 license; see the LICENSE file for details. 246 | * Calblink contains code from the [Google Calendar API 247 | Quickstart](https://developers.google.com/google-apps/calendar/quickstart/go) 248 | which is licensed under the Apache 2 license. 249 | * Calblink uses the [Go service](https://github.com/kardianos/service/) library for 250 | managing service mode. 251 | * All trademarks are the property of their respective holders. 252 | -------------------------------------------------------------------------------- /SERVICE.md: -------------------------------------------------------------------------------- 1 | # Running calblink as a service 2 | 3 | ## What does this do? 4 | 5 | Calblink now supports a mode where it runs as a service. This means that it is managed 6 | by your operating system instead of needing to manually run it. This service can be 7 | turned on and survive reboots. 8 | 9 | ## What operating systems does this mode support? 10 | 11 | It has only been tested on macOS. Theoretically it should work on Linux and other 12 | Unix-style operating systems, and might possibly work on Windows. Try it out and if 13 | you have issues, let me know. 14 | 15 | ## What potential problems are there for this mode? 16 | 17 | calblink currently doesn't cope well with not having a blink(1) installed when it is run. 18 | It will exit after enough failures to control the blink(1), if it doesn't segfault first. 19 | This mode works best for cases where a machine has a blink(1) set up at all times. 20 | Alternately, if you have a way of controlling launch daemons based on USB events 21 | (EventScripts or similar on macOS) you can use that to only run calblink when there 22 | is a blink(1) plugged in. 23 | 24 | If you don't disable the launch daemon when there isn't a blink(1) plugged in, calblink 25 | will crash and be automatically restarted every ten seconds or so. 26 | 27 | ## How do I set this up? 28 | 29 | These instructions assume macOS. 30 | 31 | 1. Install calblink like you normally would, then make sure your configuration 32 | is set up the way you want. 33 | 2. Run calblink as follows: 34 | 35 | ./calblink -runAsService -service install 36 | 37 | This will install a launch agent in ~/Library/LaunchAgents. 38 | 3. You can then control it with launchctl like any other launch agent, or run 39 | calblink to control the agent: 40 | 41 | ./calblink -runAsService -service start 42 | 43 | Available commands include `start`, `stop`, `restart`, `install`, and `uninstall`. 44 | 4. Log messages will go into your home directory, in `calblink.out.log` and 45 | `calblink.err.log`. Unless debug is turned on, there should be minimal logging. 46 | One log line is created at startup and shutdown, and fatal errors will be logged 47 | to the error log. 48 | -------------------------------------------------------------------------------- /blinker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This file manages the blink(1) state. 16 | 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | "log" 22 | "os" 23 | "os/signal" 24 | "syscall" 25 | "time" 26 | 27 | blink1 "github.com/kazrakcom/go-blink1" 28 | ) 29 | 30 | const failureRetries = 3 31 | 32 | // calendarState is a display state for the calendar event. It encapsulates both the colors to display and the flash duration. 33 | type CalendarState struct { 34 | Name string 35 | primary blink1.State 36 | secondary blink1.State 37 | primaryFlash time.Duration 38 | secondaryFlash time.Duration 39 | alternate bool 40 | } 41 | 42 | func (state CalendarState) Execute(blinker *BlinkerState) { 43 | blinker.newState <- state 44 | } 45 | 46 | var ( 47 | Black = CalendarState{Name: "Black", primary: blink1.OffState} 48 | Green = CalendarState{Name: "Green", primary: blink1.State{Green: 255}, secondary: blink1.State{Green: 255}} 49 | Yellow = CalendarState{Name: "Yellow", primary: blink1.State{Red: 255, Green: 160}, secondary: blink1.State{Red: 255, Green: 160}} 50 | Red = CalendarState{Name: "Red", primary: blink1.State{Red: 255}, secondary: blink1.State{Red: 255}} 51 | RedFlash = CalendarState{Name: "Red Flash", primary: blink1.State{Red: 255}, secondary: blink1.OffState, primaryFlash: time.Duration(500) * time.Millisecond, alternate: true} 52 | FastRedFlash = CalendarState{Name: "Fast Red Flash", primary: blink1.State{Red: 255}, secondary: blink1.OffState, primaryFlash: time.Duration(125) * time.Millisecond, alternate: true} 53 | BlueFlash = CalendarState{Name: "Red-Blue Flash", primary: blink1.State{Blue: 255}, secondary: blink1.State{Red: 255}, primaryFlash: time.Duration(500) * time.Millisecond, alternate: true} 54 | Blue = CalendarState{Name: "Blue", primary: blink1.State{Blue: 255}, secondary: blink1.State{Blue: 255}} 55 | MagentaFlash = CalendarState{Name: "MagentaFlash", primary: blink1.State{Red: 255, Blue: 255}, secondary: blink1.OffState, primaryFlash: time.Duration(125) * time.Millisecond, alternate: true} 56 | ) 57 | 58 | // Combines the two states into one state that shows both events 59 | func CombineStates(in1 CalendarState, in2 CalendarState) CalendarState { 60 | combined := CalendarState{Name: in1.Name + "/" + in2.Name, 61 | primary: in1.primary, 62 | secondary: in2.primary, 63 | primaryFlash: in1.primaryFlash, 64 | secondaryFlash: in2.primaryFlash, 65 | alternate: false} 66 | return combined 67 | } 68 | 69 | // Swaps the sides for a state, for use in flashing 70 | func SwapState(in CalendarState) CalendarState { 71 | swapped := CalendarState{Name: in.Name + " swapped", 72 | primary: in.secondary, 73 | secondary: in.primary, 74 | primaryFlash: in.secondaryFlash, 75 | secondaryFlash: in.primaryFlash, 76 | alternate: false} 77 | return swapped 78 | } 79 | 80 | // blinkerState encapsulates the current device state of the blink(1). 81 | type BlinkerState struct { 82 | device *blink1.Device 83 | newState chan CalendarState 84 | failures int 85 | maxFailures int 86 | } 87 | 88 | func NewBlinkerState(maxFailures int) *BlinkerState { 89 | blinker := &BlinkerState{ 90 | newState: make(chan CalendarState, 1), 91 | maxFailures: maxFailures, 92 | } 93 | blinker.reinitialize() 94 | return blinker 95 | } 96 | 97 | func (blinker *BlinkerState) reinitialize() error { 98 | if blinker.device != nil { 99 | blinker.device.Close() 100 | blinker.device = nil 101 | } 102 | device, err := blink1.OpenNextDevice() 103 | if err != nil { 104 | blinker.failures++ 105 | if blinker.failures > blinker.maxFailures { 106 | log.Fatalf("Unable to initialize blink(1): %v", err) 107 | } 108 | fmt.Fprint(dotOut, "X") 109 | } else { 110 | blinker.failures = 0 111 | } 112 | blinker.device = device 113 | return err 114 | } 115 | 116 | func (blinker *BlinkerState) turnOff() { 117 | blinker.device.SetState(blink1.OffState) 118 | } 119 | 120 | func (blinker *BlinkerState) setState(state blink1.State) error { 121 | if blinker.failures > 0 { 122 | err := blinker.reinitialize() 123 | if err != nil { 124 | fmt.Fprintf(debugOut, "Reinitialize failed, error %v\n", err) 125 | return err 126 | } 127 | } 128 | err := blinker.device.SetState(state) 129 | if err != nil { 130 | fmt.Fprintf(debugOut, "Re-initializing because of error %v\n", err) 131 | err = blinker.reinitialize() 132 | if err != nil { 133 | fmt.Fprintf(debugOut, "Reinitialize failed, error %v\n", err) 134 | return err 135 | } 136 | // Try one more time before giving up for this pass. 137 | err = blinker.device.SetState(state) 138 | if err != nil { 139 | fmt.Fprintf(debugOut, "Setting blinker state failed, error %v\n", err) 140 | } 141 | } else { 142 | blinker.failures = 0 143 | } 144 | return err 145 | } 146 | 147 | func (blinker *BlinkerState) patternRunner() { 148 | currentState := Black 149 | failing := false 150 | err := blinker.setState(currentState.primary) 151 | if err != nil { 152 | failing = true 153 | } 154 | 155 | var ticker <-chan time.Time 156 | stateFlip := false 157 | for { 158 | select { 159 | case newState := <-blinker.newState: 160 | if newState != currentState || failing { 161 | fmt.Fprintf(debugOut, "Changing from state %v to %v\n", currentState, newState) 162 | currentState = newState 163 | if newState.primaryFlash > 0 || newState.secondaryFlash > 0 { 164 | ticker = time.After(time.Millisecond) 165 | } else { 166 | if ticker != nil { 167 | fmt.Fprintf(debugOut, "Killing timer\n") 168 | ticker = nil 169 | } 170 | state1 := newState.primary 171 | state1.LED = blink1.LED1 172 | state2 := newState.secondary 173 | state2.LED = blink1.LED2 174 | err1 := blinker.setState(state1) 175 | err2 := blinker.setState(state2) 176 | failing = (err1 != nil) || (err2 != nil) 177 | } 178 | } else { 179 | fmt.Fprintf(debugOut, "Retaining state %v unchanged\n", newState) 180 | } 181 | 182 | case <-ticker: 183 | fmt.Fprintf(debugOut, "Timer fired\n") 184 | state1 := currentState.primary 185 | state2 := currentState.secondary 186 | if stateFlip { 187 | if currentState.alternate { 188 | state1, state2 = state2, state1 189 | } else { 190 | if currentState.primaryFlash > 0 { 191 | state1 = blink1.OffState 192 | } 193 | if currentState.secondaryFlash > 0 { 194 | state2 = blink1.OffState 195 | } 196 | } 197 | } 198 | state1.Duration = currentState.primaryFlash 199 | state1.FadeTime = state1.Duration 200 | if currentState.alternate { 201 | state2.Duration, state2.FadeTime = state1.Duration, state1.FadeTime 202 | 203 | } else { 204 | state2.Duration = currentState.secondaryFlash 205 | state2.FadeTime = state2.Duration 206 | } 207 | // We set state1 on LED 1 and state2 on LED 2. On an original (mk1) blink(1) state2 will be ignored. 208 | state1.LED = blink1.LED1 209 | state2.LED = blink1.LED2 210 | fmt.Fprintf(debugOut, "Setting state (%v and %v)\n", state1, state2) 211 | err1 := blinker.setState(state1) 212 | err2 := blinker.setState(state2) 213 | failing = (err1 != nil) || (err2 != nil) 214 | stateFlip = !stateFlip 215 | nextTick := state1.Duration 216 | if state1.Duration == 0 { 217 | nextTick = state2.Duration 218 | } 219 | fmt.Fprintf(debugOut, "Next tick: %s\n", nextTick) 220 | ticker = time.After(nextTick) 221 | } 222 | } 223 | } 224 | 225 | // Signal handler - SIGINT or SIGKILL should turn off the blinker before we exit. 226 | // SIGQUIT should turn on debug mode. 227 | 228 | func signalHandler(blinker *BlinkerState) { 229 | interrupt := make(chan os.Signal, 1) 230 | signal.Notify(interrupt, os.Interrupt, os.Kill, syscall.SIGQUIT) 231 | for { 232 | s := <-interrupt 233 | if s == syscall.SIGQUIT { 234 | fmt.Println("Turning on debug mode.") 235 | debugOut = os.Stdout 236 | continue 237 | } 238 | if blinker.failures == 0 { 239 | blinker.turnOff() 240 | } 241 | log.Fatalf("Quitting due to signal %v", s) 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /calblink.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "flag" 19 | "fmt" 20 | "io" 21 | "log" 22 | "os" 23 | "time" 24 | 25 | "github.com/kardianos/service" 26 | ) 27 | 28 | // flags 29 | var debugFlag = flag.Bool("debug", false, "Show debug messages") 30 | var clientSecretFlag = flag.String("clientsecret", "client_secret.json", "Path to JSON file containing client secret") 31 | var calNameFlag = flag.String("calendar", "primary", "Name of calendar to base blinker on (overrides value in config file)") 32 | var configFileFlag = flag.String("config", "conf.json", "Path to configuration file") 33 | var pollIntervalFlag = flag.Int("poll_interval", 30, "Number of seconds between polls of calendar API (overrides value in config file)") 34 | var responseStateFlag = flag.String("response_state", "notRejected", "Which events to consider based on response: all, accepted, or notRejected") 35 | var deviceFailureRetriesFlag = flag.Int("device_failure_retries", 10, "Number of times to retry initializing the device before quitting the program") 36 | var showDotsFlag = flag.Bool("show_dots", true, "Whether to show progress dots after every cycle of checking the calendar") 37 | var runAsServiceFlag = flag.Bool("runAsService", false, "Whether to run as a service or remain live in the current shell") 38 | var serviceFlag = flag.String("service", "", "Control the system service.") 39 | 40 | var debugOut io.Writer = io.Discard 41 | var dotOut io.Writer = io.Discard 42 | 43 | // Necessary status for running as a service 44 | type program struct { 45 | service service.Service 46 | userPrefs *UserPrefs 47 | exit chan struct{} 48 | } 49 | 50 | // Time calculation methods 51 | 52 | func tomorrow() time.Time { 53 | now := time.Now() 54 | return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) 55 | } 56 | 57 | func setHourMinuteFromTime(t time.Time) time.Time { 58 | now := time.Now() 59 | return time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, now.Location()) 60 | } 61 | 62 | // Print output methods 63 | 64 | func usage() { 65 | fmt.Fprintf(os.Stderr, "Usage:\n") 66 | flag.PrintDefaults() 67 | } 68 | 69 | func main() { 70 | flag.Usage = usage 71 | flag.Parse() 72 | 73 | if *debugFlag { 74 | debugOut = os.Stdout 75 | } 76 | 77 | userPrefs := readUserPrefs() 78 | isService := false 79 | serviceCmd := "" 80 | 81 | // Overrides from command-line 82 | flag.Visit(func(myFlag *flag.Flag) { 83 | switch myFlag.Name { 84 | case "calendar": 85 | userPrefs.Calendars = []string{myFlag.Value.String()} 86 | case "poll_interval": 87 | userPrefs.PollInterval = myFlag.Value.(flag.Getter).Get().(int) 88 | case "response_state": 89 | userPrefs.ResponseState = ResponseState(myFlag.Value.String()) 90 | if !userPrefs.ResponseState.isValidState() { 91 | log.Fatalf("Invalid response state %v", userPrefs.ResponseState) 92 | } 93 | case "device_failure_retries": 94 | userPrefs.DeviceFailureRetries = myFlag.Value.(flag.Getter).Get().(int) 95 | case "show_dots": 96 | userPrefs.ShowDots = myFlag.Value.(flag.Getter).Get().(bool) 97 | case "runAsService": 98 | isService = myFlag.Value.(flag.Getter).Get().(bool) 99 | case "service": 100 | serviceCmd = myFlag.Value.String() 101 | } 102 | }) 103 | 104 | if userPrefs.ShowDots && !isService { 105 | dotOut = os.Stdout 106 | } 107 | 108 | prg := &program{ 109 | userPrefs: userPrefs, 110 | exit: make(chan struct{}), 111 | } 112 | 113 | if isService { 114 | prg.StartService(serviceCmd) 115 | } else { 116 | runLoop(prg) 117 | } 118 | 119 | } 120 | 121 | func runLoop(p *program) { 122 | userPrefs := p.userPrefs 123 | srv, err := Connect() 124 | if err != nil { 125 | log.Fatalf("Unable to retrieve Calendar client: %v", err) 126 | } 127 | 128 | blinkerState := NewBlinkerState(userPrefs.DeviceFailureRetries) 129 | 130 | go signalHandler(blinkerState) 131 | go blinkerState.patternRunner() 132 | 133 | if p.service == nil { 134 | printStartInfo(userPrefs) 135 | } else { 136 | fmt.Printf("Calblink starting at %v\n", time.Now()) 137 | } 138 | 139 | ticker := time.NewTicker(time.Second) 140 | nextEvent := time.Now() 141 | failures := 0 142 | 143 | for { 144 | select { 145 | case <-p.exit: 146 | blinkerState.turnOff() 147 | fmt.Printf("Calblink exiting at %v\n", time.Now()) 148 | ticker.Stop() 149 | return 150 | case now := <-ticker.C: 151 | if nextEvent.After(now) { 152 | continue 153 | } 154 | weekday := now.Weekday() 155 | if userPrefs.SkipDays[weekday] { 156 | tomorrow := tomorrow() 157 | Black.Execute(blinkerState) 158 | fmt.Fprintf(debugOut, "Sleeping until tomorrow (%v) because it's a skip day\n", tomorrow) 159 | fmt.Fprint(dotOut, "~") 160 | nextEvent = tomorrow 161 | continue 162 | } 163 | if userPrefs.StartTime != nil { 164 | start := setHourMinuteFromTime(*userPrefs.StartTime) 165 | fmt.Fprintf(debugOut, "Start time: %v\n", start) 166 | if diff := time.Since(start); diff < 0 { 167 | Black.Execute(blinkerState) 168 | fmt.Fprintf(debugOut, "Sleeping %v because start time after now\n", -diff) 169 | fmt.Fprint(dotOut, ">") 170 | nextEvent = start 171 | continue 172 | } 173 | } 174 | if userPrefs.EndTime != nil { 175 | end := setHourMinuteFromTime(*userPrefs.EndTime) 176 | fmt.Fprintf(debugOut, "End time: %v\n", end) 177 | if diff := time.Since(end); diff > 0 { 178 | Black.Execute(blinkerState) 179 | tomorrow := tomorrow() 180 | untilTomorrow := tomorrow.Sub(now) 181 | fmt.Fprintf(debugOut, "Sleeping %v until tomorrow because end time %v before now\n", untilTomorrow, diff) 182 | fmt.Fprint(dotOut, "<") 183 | nextEvent = tomorrow 184 | continue 185 | } 186 | } 187 | next, err := fetchEvents(now, srv, userPrefs) 188 | if err != nil { 189 | // Leave the same color, set a flag. If we get more than a critical number of these, 190 | // set the color to blinking magenta to tell the user we are in a failed state. 191 | failures++ 192 | if failures > failureRetries { 193 | MagentaFlash.Execute(blinkerState) 194 | } 195 | fmt.Fprintf(debugOut, "Fetch Error:\n%v\n", err) 196 | fmt.Fprint(dotOut, ",") 197 | nextEvent = now.Add(time.Duration(userPrefs.PollInterval) * time.Second) 198 | continue 199 | } else { 200 | failures = 0 201 | } 202 | blinkState := blinkStateForEvent(next, userPrefs.PriorityFlashSide) 203 | 204 | blinkState.Execute(blinkerState) 205 | fmt.Fprint(dotOut, ".") 206 | nextEvent = now.Add(time.Duration(userPrefs.PollInterval) * time.Second) 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /calendar.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This file manages retrieving and filtering events from Google Calendar. 16 | 17 | package main 18 | 19 | import ( 20 | "fmt" 21 | "log" 22 | "sort" 23 | "strings" 24 | "time" 25 | 26 | "google.golang.org/api/calendar/v3" 27 | ) 28 | 29 | // Event handling methods 30 | func eventHasAcceptableResponse(item *calendar.Event, responseState ResponseState) bool { 31 | for _, attendee := range item.Attendees { 32 | if attendee.Self { 33 | return responseState.CheckStatus(attendee.ResponseStatus) 34 | } 35 | } 36 | fmt.Fprintf(debugOut, "No self attendee found for %v\n", item) 37 | fmt.Fprintf(debugOut, "Attendees: %v\n", item.Attendees) 38 | return true 39 | } 40 | 41 | func eventExcludedByPrefs(item string, userPrefs *UserPrefs) bool { 42 | if userPrefs.Excludes[item] { 43 | return true 44 | } 45 | for _, prefix := range userPrefs.ExcludePrefixes { 46 | if strings.HasPrefix(item, prefix) { 47 | fmt.Fprintf(debugOut, "Skipping event '%v' due to prefix match '%v'\n", item, prefix) 48 | return true 49 | } 50 | } 51 | return false 52 | } 53 | 54 | func nextEvent(items []*calendar.Event, locations []WorkSite, userPrefs *UserPrefs) []*calendar.Event { 55 | var events []*calendar.Event 56 | 57 | if len(userPrefs.WorkingLocations) > 0 { 58 | match := false 59 | locationSet := make(map[WorkSite]bool) 60 | for _, location := range locations { 61 | locationSet[location] = true 62 | } 63 | 64 | for _, prefLocation := range userPrefs.WorkingLocations { 65 | if locationSet[prefLocation] { 66 | fmt.Fprintf(debugOut, "Found matching location: %v\n", prefLocation) 67 | match = true 68 | break 69 | } 70 | } 71 | 72 | if !match { 73 | fmt.Fprintf(debugOut, "Skipping all events due to no matching locations in %v\n", locations) 74 | return events 75 | } 76 | } 77 | 78 | for _, i := range items { 79 | if i.Start.DateTime != "" && 80 | !eventExcludedByPrefs(i.Summary, userPrefs) && 81 | eventHasAcceptableResponse(i, userPrefs.ResponseState) { 82 | events = append(events, i) 83 | if len(events) == 2 || (len(events) == 1 && !userPrefs.MultiEvent) { 84 | break 85 | } 86 | } 87 | } 88 | fmt.Fprintf(debugOut, "nextEvent returning %d events\n", len(events)) 89 | return events 90 | } 91 | 92 | func blinkStateForDelta(delta float64) CalendarState { 93 | blinkState := Black 94 | switch { 95 | case delta < -1: 96 | blinkState = Blue 97 | case delta < 0: 98 | blinkState = BlueFlash 99 | case delta < 2: 100 | blinkState = FastRedFlash 101 | case delta < 5: 102 | blinkState = RedFlash 103 | case delta < 10: 104 | blinkState = Red 105 | case delta < 30: 106 | blinkState = Yellow 107 | case delta < 60: 108 | blinkState = Green 109 | } 110 | return blinkState 111 | } 112 | 113 | func blinkStateForEvent(next []*calendar.Event, priority int) CalendarState { 114 | blinkState := Black 115 | for i, event := range next { 116 | startTime, err := time.Parse(time.RFC3339, event.Start.DateTime) 117 | if err == nil { 118 | delta := -time.Since(startTime).Minutes() 119 | if i == 0 { 120 | blinkState = blinkStateForDelta(delta) 121 | } else { 122 | secondary := blinkStateForDelta(delta) 123 | if secondary != Black { 124 | blinkState = CombineStates(blinkState, secondary) 125 | } 126 | if (priority == 1 && blinkState.primaryFlash == 0 && blinkState.secondaryFlash > 0) || 127 | (priority == 2 && blinkState.primaryFlash > 0 && blinkState.secondaryFlash == 0) { 128 | fmt.Fprintf(debugOut, "Swapping") 129 | blinkState = SwapState(blinkState) 130 | } 131 | } 132 | fmt.Fprintf(debugOut, "Event %v, time %v, delta %v, state %v\n", event.Summary, startTime, delta, blinkState.Name) 133 | // Set priority. If priority is set, and the other light is flashing but the priority one isn't, swap them. 134 | 135 | } else { 136 | fmt.Println(err) 137 | break 138 | } 139 | } 140 | return blinkState 141 | } 142 | 143 | func fetchEvents(now time.Time, srv *calendar.Service, userPrefs *UserPrefs) ([]*calendar.Event, error) { 144 | start := now.Format(time.RFC3339) 145 | endTime := now.Add(2 * time.Hour) 146 | end := endTime.Format(time.RFC3339) 147 | var allEvents []*calendar.Event 148 | locations := make([]WorkSite, 0) 149 | for _, calendar := range userPrefs.Calendars { 150 | var locationCreated time.Time 151 | var location WorkSite 152 | events, err := srv.Events.List(calendar).ShowDeleted(false). 153 | SingleEvents(true).TimeMin(start).TimeMax(end).OrderBy("startTime"). 154 | EventTypes("default", "focusTime", "outOfOffice", "workingLocation").Do() 155 | if err != nil { 156 | return nil, err 157 | } 158 | for _, event := range events.Items { 159 | if event.EventType == "workingLocation" { 160 | // There's a bug in the Calendar API where a recurring location that is 161 | // overridden for the day still shows up in the list of events. The most 162 | // recently created one is the one we want. 163 | thisCreated, err := time.Parse(time.RFC3339, event.Created) 164 | if err != nil || thisCreated.Before(locationCreated) { 165 | continue 166 | } 167 | locationProperties := event.WorkingLocationProperties 168 | locationType := makeWorkSiteType(locationProperties.Type) 169 | locationString := "" 170 | switch locationType { 171 | case WorkSiteOffice: 172 | locationString = locationProperties.OfficeLocation.Label 173 | case WorkSiteCustom: 174 | locationString = locationProperties.CustomLocation.Label 175 | } 176 | location = WorkSite{SiteType: locationType, Name: locationString} 177 | locationCreated = thisCreated 178 | fmt.Fprintf(debugOut, "Location detected: calendar %v, location %v\n", calendar, location) 179 | } 180 | } 181 | if !locationCreated.IsZero() { 182 | fmt.Fprintf(debugOut, "Adding final location %v\n", location) 183 | locations = append(locations, location) 184 | } 185 | allEvents = append(allEvents, events.Items...) 186 | } 187 | if len(userPrefs.Calendars) > 1 { 188 | // Filter out copies of the same event, or ones with times that don't parse. 189 | var filtered []*calendar.Event 190 | seen := make(map[string]bool) 191 | for _, event := range allEvents { 192 | if seen[event.Id] { 193 | fmt.Fprintf(debugOut, "Skipping duplicate event with ID %v\n", event.Id) 194 | continue 195 | } 196 | if event.Start.DateTime == "" { 197 | fmt.Fprintf(debugOut, "Skipping all-day event %v\n", event.Summary) 198 | continue 199 | } 200 | filtered = append(filtered, event) 201 | seen[event.Id] = true 202 | } 203 | sort.SliceStable(filtered, func(i, j int) bool { 204 | t1, err1 := time.Parse(time.RFC3339, filtered[i].Start.DateTime) 205 | t2, err2 := time.Parse(time.RFC3339, filtered[j].Start.DateTime) 206 | // We should have filtered any bad times out already, so this is a fatal error. 207 | if err1 != nil { 208 | log.Fatalf("Found bad time after times should have been filtered out: %v\n", err1) 209 | } 210 | if err2 != nil { 211 | log.Fatalf("Found bad time after times should have been filtered out: %v\n", err2) 212 | } 213 | return t1.Before(t2) 214 | }) 215 | allEvents = filtered 216 | } 217 | return nextEvent(allEvents, locations, userPrefs), nil 218 | } 219 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This file manages reading the user configuration file. 16 | 17 | package main 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "log" 23 | "os" 24 | "strings" 25 | "time" 26 | ) 27 | 28 | // Configuration file: 29 | // JSON file with the following structure: 30 | // { 31 | // excludes: [ "event", "names", "to", "ignore"], 32 | // excludePrefixes: [ "prefixes", "to", "ignore"], 33 | // startTime: "hh:mm (24 hr format) to start blinking at every day", 34 | // endTime: "hh:mm (24 hr format) to stop blinking at every day", 35 | // skipDays: [ "weekdays", "to", "skip"], 36 | // pollInterval: 30 37 | // calendar: "calendar" 38 | // responseState: "all" 39 | // deviceFailureRetries: 10 40 | // showDots: true 41 | // multiEvent: true 42 | // priorityFlashSide: 1 43 | //} 44 | // Notes on items: 45 | // Calendar is the calendar ID - the email address of the calendar. For a person's calendar, that's their email. 46 | // For a secondary calendar, it's the base64 string @group.calendar.google.com on the calendar details page. "primary" 47 | // is a magic string that means "the logged-in user's primary calendar". 48 | // SkipDays may be localized. 49 | // Excludes is exact string matches only. 50 | // ExcludePrefixes will exclude all events starting with the given prefix. 51 | // ResponseState can be one of: "all" (all events whatever their response status), "accepted" (only accepted events), 52 | // "notRejected" (any events that are not rejected). Default is notRejected. 53 | // DeviceFailureRetries is the number of consecutive failures to initialize the device before the program quits. Default is 10. 54 | // ShowDots indicates whether to show dots and similar marks to indicate that the program has completed an update cycle. 55 | // MultiEvent indicates whether to show two events if there are multiple events in the time range. 56 | // userPrefs is a struct that manages the user preferences as set by the config file and command line. 57 | 58 | type UserPrefs struct { 59 | Excludes map[string]bool 60 | ExcludePrefixes []string 61 | StartTime *time.Time 62 | EndTime *time.Time 63 | SkipDays [7]bool 64 | PollInterval int 65 | Calendars []string 66 | ResponseState ResponseState 67 | DeviceFailureRetries int 68 | ShowDots bool 69 | MultiEvent bool 70 | PriorityFlashSide int 71 | WorkingLocations []WorkSite 72 | } 73 | 74 | // Struct used for decoding the JSON 75 | type prefLayout struct { 76 | Excludes []string 77 | ExcludePrefixes []string 78 | StartTime string 79 | EndTime string 80 | SkipDays []string 81 | PollInterval int64 82 | Calendar string 83 | Calendars []string 84 | ResponseState string 85 | DeviceFailureRetries int64 86 | ShowDots string 87 | MultiEvent string 88 | PriorityFlashSide int64 89 | WorkingLocations []string 90 | } 91 | 92 | // responseState is an enumerated list of event response states, used to control which events will activate the blink(1). 93 | type ResponseState string 94 | 95 | const ( 96 | ResponseStateAll = ResponseState("all") 97 | ResponseStateAccepted = ResponseState("accepted") 98 | ResponseStateNotRejected = ResponseState("notRejected") 99 | ) 100 | 101 | // checkStatus returns true if the given event status is one that should activate the blink(1) in the given responseState. 102 | func (state ResponseState) CheckStatus(status string) bool { 103 | switch state { 104 | case ResponseStateAll: 105 | return true 106 | 107 | case ResponseStateAccepted: 108 | return (status == "accepted") 109 | 110 | case ResponseStateNotRejected: 111 | return (status != "declined") 112 | } 113 | return false 114 | } 115 | 116 | func (state ResponseState) isValidState() bool { 117 | switch state { 118 | case ResponseStateAll: 119 | return true 120 | case ResponseStateAccepted: 121 | return true 122 | case ResponseStateNotRejected: 123 | return true 124 | } 125 | return false 126 | } 127 | 128 | // Work site information 129 | 130 | type WorkSiteType int 131 | 132 | const ( 133 | WorkSiteHome WorkSiteType = iota 134 | WorkSiteOffice 135 | WorkSiteCustom 136 | ) 137 | 138 | func makeWorkSiteType(location string) WorkSiteType { 139 | switch location { 140 | case "officeLocation", "office": 141 | return WorkSiteOffice 142 | case "customLocation", "custom": 143 | return WorkSiteCustom 144 | } 145 | return WorkSiteHome 146 | } 147 | 148 | func (siteType WorkSiteType) toString() string { 149 | switch siteType { 150 | case WorkSiteHome: 151 | return "Home" 152 | case WorkSiteOffice: 153 | return "Office" 154 | case WorkSiteCustom: 155 | return "Custom" 156 | } 157 | return "" 158 | } 159 | 160 | // workSite is a struct that holds a working location. If name is unset, should match 161 | // all sites of the given type. 162 | type WorkSite struct { 163 | SiteType WorkSiteType 164 | Name string 165 | } 166 | 167 | // Converts a string into a workSite structure. Returns an unset structure if the string is invalid. 168 | func makeWorkSite(location string) WorkSite { 169 | split := strings.SplitN(location, ":", 2) 170 | siteType := makeWorkSiteType(split[0]) 171 | name := "" 172 | if len(split) > 1 { 173 | name = split[1] 174 | } 175 | fmt.Fprintf(debugOut, "Work Site: type %v, name %v", siteType, name) 176 | return WorkSite{SiteType: siteType, Name: name} 177 | } 178 | 179 | // User preferences methods 180 | 181 | func readUserPrefs() *UserPrefs { 182 | userPrefs := &UserPrefs{} 183 | // Set defaults from command line 184 | userPrefs.PollInterval = *pollIntervalFlag 185 | userPrefs.Calendars = []string{*calNameFlag} 186 | userPrefs.ResponseState = ResponseState(*responseStateFlag) 187 | userPrefs.DeviceFailureRetries = *deviceFailureRetriesFlag 188 | userPrefs.ShowDots = *showDotsFlag 189 | file, err := os.Open(*configFileFlag) 190 | defer file.Close() 191 | if err != nil { 192 | // Lack of a config file is not a fatal error. 193 | fmt.Fprintf(debugOut, "Unable to read config file %v : %v\n", *configFileFlag, err) 194 | return userPrefs 195 | } 196 | prefs := prefLayout{} 197 | decoder := json.NewDecoder(file) 198 | decoder.DisallowUnknownFields() 199 | err = decoder.Decode(&prefs) 200 | fmt.Fprintf(debugOut, "Decoded prefs: %v\n", prefs) 201 | if err != nil { 202 | log.Fatalf("Unable to parse config file %v", err) 203 | } 204 | if prefs.StartTime != "" { 205 | startTime, err := time.Parse("15:04", prefs.StartTime) 206 | if err != nil { 207 | log.Fatalf("Invalid start time %v : %v", prefs.StartTime, err) 208 | } 209 | userPrefs.StartTime = &startTime 210 | } 211 | if prefs.EndTime != "" { 212 | endTime, err := time.Parse("15:04", prefs.EndTime) 213 | if err != nil { 214 | log.Fatalf("Invalid end time %v : %v", prefs.EndTime, err) 215 | } 216 | userPrefs.EndTime = &endTime 217 | } 218 | userPrefs.Excludes = make(map[string]bool) 219 | for _, item := range prefs.Excludes { 220 | fmt.Fprintf(debugOut, "Excluding item %v\n", item) 221 | userPrefs.Excludes[item] = true 222 | } 223 | userPrefs.ExcludePrefixes = prefs.ExcludePrefixes 224 | weekdays := make(map[string]int) 225 | for i := 0; i < 7; i++ { 226 | weekdays[time.Weekday(i).String()] = i 227 | } 228 | for _, day := range prefs.SkipDays { 229 | i, ok := weekdays[day] 230 | if ok { 231 | userPrefs.SkipDays[i] = true 232 | } else { 233 | log.Fatalf("Invalid day in skipdays: %v", day) 234 | } 235 | } 236 | if prefs.Calendar != "" { 237 | userPrefs.Calendars = []string{prefs.Calendar} 238 | } 239 | if len(prefs.Calendars) > 0 { 240 | userPrefs.Calendars = prefs.Calendars 241 | } 242 | if prefs.PollInterval != 0 { 243 | userPrefs.PollInterval = int(prefs.PollInterval) 244 | } 245 | if prefs.ResponseState != "" { 246 | userPrefs.ResponseState = ResponseState(prefs.ResponseState) 247 | if !userPrefs.ResponseState.isValidState() { 248 | log.Fatalf("Invalid response state %v", prefs.ResponseState) 249 | } 250 | } 251 | if prefs.DeviceFailureRetries != 0 { 252 | userPrefs.DeviceFailureRetries = int(prefs.DeviceFailureRetries) 253 | } 254 | if prefs.ShowDots != "" { 255 | userPrefs.ShowDots = (prefs.ShowDots == "true") 256 | } 257 | userPrefs.MultiEvent = (prefs.MultiEvent == "true") 258 | if prefs.PriorityFlashSide != 0 { 259 | userPrefs.PriorityFlashSide = int(prefs.PriorityFlashSide) 260 | } 261 | for _, location := range prefs.WorkingLocations { 262 | userPrefs.WorkingLocations = append(userPrefs.WorkingLocations, makeWorkSite(location)) 263 | } 264 | fmt.Fprintf(debugOut, "User prefs: %v\n", userPrefs) 265 | return userPrefs 266 | } 267 | 268 | func printStartInfo(userPrefs *UserPrefs) { 269 | fmt.Printf("Running with %v second intervals\n", userPrefs.PollInterval) 270 | if len(userPrefs.Calendars) == 1 { 271 | fmt.Printf("Monitoring calendar ID %v\n", userPrefs.Calendars[0]) 272 | } else { 273 | fmt.Println("Monitoring calendar IDs:") 274 | for _, item := range userPrefs.Calendars { 275 | fmt.Printf(" %v\n", item) 276 | } 277 | } 278 | switch userPrefs.ResponseState { 279 | case ResponseStateAll: 280 | fmt.Println("All events shown, regardless of accepted/rejected status.") 281 | case ResponseStateAccepted: 282 | fmt.Println("Only accepted events shown.") 283 | case ResponseStateNotRejected: 284 | fmt.Println("Rejected events not shown.") 285 | } 286 | if len(userPrefs.WorkingLocations) > 0 { 287 | fmt.Println("Working Locations:") 288 | for _, item := range userPrefs.WorkingLocations { 289 | if item.SiteType == WorkSiteHome { 290 | fmt.Printf(" Home\n") 291 | } else { 292 | fmt.Printf(" %v: %v\n", item.SiteType.toString(), item.Name) 293 | } 294 | } 295 | } 296 | if len(userPrefs.Excludes) > 0 { 297 | fmt.Println("Excluded events:") 298 | for item := range userPrefs.Excludes { 299 | fmt.Printf(" %v\n", item) 300 | } 301 | } 302 | if len(userPrefs.ExcludePrefixes) > 0 { 303 | fmt.Println("Excluded event prefixes:") 304 | for _, item := range userPrefs.ExcludePrefixes { 305 | fmt.Printf(" %v\n", item) 306 | } 307 | } 308 | skipDays := "" 309 | join := "" 310 | for i, val := range userPrefs.SkipDays { 311 | if val { 312 | skipDays += join 313 | skipDays += time.Weekday(i).String() 314 | join = ", " 315 | } 316 | } 317 | if len(skipDays) > 0 { 318 | fmt.Println("Skip days: " + skipDays) 319 | } 320 | timeString := "" 321 | if userPrefs.StartTime != nil { 322 | timeString += fmt.Sprintf("Time restrictions: after %02d:%02d", userPrefs.StartTime.Hour(), userPrefs.StartTime.Minute()) 323 | } 324 | if userPrefs.EndTime != nil { 325 | endTimeString := fmt.Sprintf("until %02d:%02d", userPrefs.EndTime.Hour(), userPrefs.EndTime.Minute()) 326 | if len(timeString) > 0 { 327 | timeString += " and " 328 | } else { 329 | timeString += "Time restrictions: " 330 | } 331 | timeString += endTimeString 332 | } 333 | if len(timeString) > 0 { 334 | fmt.Println(timeString) 335 | } 336 | if userPrefs.MultiEvent { 337 | fmt.Println("Multievent is active.") 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /network.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This file manages network authentication and retrieval. 16 | 17 | package main 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "log" 23 | "net/http" 24 | "net/url" 25 | "os" 26 | "os/user" 27 | "path/filepath" 28 | 29 | "google.golang.org/api/calendar/v3" 30 | 31 | "golang.org/x/net/context" 32 | "golang.org/x/oauth2" 33 | "golang.org/x/oauth2/google" 34 | "google.golang.org/api/option" 35 | ) 36 | 37 | // Connect to the Calendar API 38 | 39 | // Verify that the client credential permissions are correct before reading them. 40 | func loadClientCredentials(clientSecretPath string) ([]byte, error) { 41 | // Check if the file exists and is readable 42 | info, err := os.Stat(clientSecretPath) 43 | if os.IsNotExist(err) { 44 | return nil, fmt.Errorf("client secret file not found: %s", clientSecretPath) 45 | } 46 | // Check if the file has secure permissions (readable only by owner) 47 | if info.Mode().Perm()&077 != 0 { 48 | return nil, fmt.Errorf("insecure permissions for client secret file: %s", clientSecretPath) 49 | } 50 | // Read the contents of the file 51 | content, err := os.ReadFile(clientSecretPath) 52 | if err != nil { 53 | return nil, fmt.Errorf("failed to read client secret file: %v", err) 54 | } 55 | return content, nil 56 | } 57 | 58 | func Connect() (*calendar.Service, error) { 59 | // BEGIN GOOGLE CALENDAR API SAMPLE CODE 60 | ctx := context.Background() 61 | 62 | b, err := loadClientCredentials(*clientSecretFlag) 63 | if err != nil { 64 | log.Fatalf("Unable to read client secret file: %v", err) 65 | } 66 | 67 | config, err := google.ConfigFromJSON(b, calendar.CalendarReadonlyScope) 68 | if err != nil { 69 | log.Fatalf("Unable to parse client secret file to config: %v", err) 70 | } 71 | client := getClient(ctx, config) 72 | 73 | srv, err := calendar.NewService(ctx, option.WithHTTPClient(client)) 74 | if err != nil { 75 | log.Fatalf("Unable to retrieve Calendar client: %v", err) 76 | } 77 | // END GOOGLE CALENDAR API SAMPLE CODE 78 | return srv, nil 79 | } 80 | 81 | // HTTP server code to listen to localhost to get an OAuth2 token. 82 | 83 | type handler struct { 84 | rChan chan string 85 | srv *http.Server 86 | } 87 | 88 | func (h handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 89 | fmt.Fprintln(debugOut, "Starting HTTP handler") 90 | url := req.URL 91 | if url.Path == "/" { 92 | fmt.Fprintf(w, "Token received. You can close this window.") 93 | val := url.Query() 94 | code := val["code"][0] 95 | fmt.Fprintf(debugOut, "Received code %v\n", code) 96 | go h.srv.Shutdown(context.Background()) 97 | h.rChan <- code 98 | } else { 99 | w.WriteHeader(http.StatusNotFound) 100 | } 101 | } 102 | 103 | func getTokenFromServer() string { 104 | rChan := make(chan (string)) 105 | srv := &http.Server{ 106 | Addr: ":8844", 107 | } 108 | srv.Handler = handler{ 109 | rChan: rChan, 110 | srv: srv, 111 | } 112 | srv.ListenAndServe() 113 | 114 | code := <-rChan 115 | 116 | return code 117 | } 118 | 119 | // BEGIN GOOGLE CALENDAR API SAMPLE CODE 120 | 121 | // getClient uses a Context and Config to retrieve a Token 122 | // then generate a Client. It returns the generated Client. 123 | func getClient(ctx context.Context, config *oauth2.Config) *http.Client { 124 | cacheFile, err := tokenCacheFile() 125 | if err != nil { 126 | log.Fatalf("Unable to get path to cached credential file. %v", err) 127 | } 128 | tok, err := tokenFromFile(cacheFile) 129 | if err != nil { 130 | tok = getTokenFromWeb(config) 131 | saveToken(cacheFile, tok) 132 | } 133 | return config.Client(ctx, tok) 134 | } 135 | 136 | // getTokenFromWeb uses Config to request a Token. 137 | // It returns the retrieved Token. 138 | // Modified from original Google code to use localhost redirect instead of OOB. 139 | func getTokenFromWeb(config *oauth2.Config) *oauth2.Token { 140 | config.RedirectURL = "http://localhost:8844" 141 | authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) 142 | fmt.Printf("Go to the following link in your browser: \n%v\n", authURL) 143 | 144 | code := getTokenFromServer() 145 | 146 | tok, err := config.Exchange(oauth2.NoContext, code) 147 | if err != nil { 148 | log.Fatalf("Unable to retrieve token from web %v", err) 149 | } 150 | return tok 151 | } 152 | 153 | // tokenCacheFile generates credential file path/filename. 154 | // It returns the generated credential path/filename. 155 | func tokenCacheFile() (string, error) { 156 | usr, err := user.Current() 157 | if err != nil { 158 | return "", err 159 | } 160 | tokenCacheDir := filepath.Join(usr.HomeDir, ".credentials") 161 | os.MkdirAll(tokenCacheDir, 0700) 162 | return filepath.Join(tokenCacheDir, 163 | url.QueryEscape("calendar-blink1.json")), err 164 | } 165 | 166 | // tokenFromFile retrieves a Token from a given file path. 167 | // It returns the retrieved Token and any read error encountered. 168 | func tokenFromFile(file string) (*oauth2.Token, error) { 169 | f, err := os.Open(file) 170 | if err != nil { 171 | return nil, err 172 | } 173 | defer f.Close() 174 | t := &oauth2.Token{} 175 | err = json.NewDecoder(f).Decode(t) 176 | return t, err 177 | } 178 | 179 | // saveToken uses a file path to create a file and store the 180 | // token in it. 181 | func saveToken(file string, token *oauth2.Token) { 182 | fmt.Printf("Saving credential file to: %s\n", file) 183 | f, err := os.Create(file) 184 | if err != nil { 185 | log.Fatalf("Unable to cache oauth token: %v", err) 186 | } 187 | defer f.Close() 188 | json.NewEncoder(f).Encode(token) 189 | } 190 | 191 | // END GOOGLE CALENDAR API SAMPLE CODE 192 | -------------------------------------------------------------------------------- /service.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This file manages running calblink as a service. 16 | 17 | package main 18 | 19 | import ( 20 | "log" 21 | "os" 22 | 23 | "github.com/kardianos/service" 24 | ) 25 | 26 | func (p *program) StartService(serviceCmd string) { 27 | dir, err := os.Getwd() 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | svcConfig := &service.Config{ 32 | Name: "calblink", 33 | DisplayName: "calblink", 34 | Description: "Service to monitor Google Calendar to control a blink(1)", 35 | Arguments: []string{"-runAsService"}, 36 | WorkingDirectory: dir, 37 | Option: service.KeyValue{ 38 | "UserService": true, 39 | }, 40 | } 41 | s, err := service.New(p, svcConfig) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | p.service = s 46 | if len(serviceCmd) != 0 { 47 | err := service.Control(s, serviceCmd) 48 | if err != nil { 49 | log.Printf("Valid actions: %q\n", service.ControlAction) 50 | log.Fatal(err) 51 | } 52 | return 53 | } 54 | err = s.Run() 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | } 59 | 60 | func (p *program) Start(s service.Service) error { 61 | go runLoop(p) 62 | return nil 63 | } 64 | 65 | func (p *program) Stop(s service.Service) error { 66 | close(p.exit) 67 | return nil 68 | } 69 | --------------------------------------------------------------------------------