├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── examples ├── docker │ ├── Dockerfile │ ├── README.md │ ├── docker-compose.yaml │ └── wrapper.sh ├── lxc │ └── README.md ├── raspberry-pi │ └── README.md ├── scripts │ ├── cpu_temp.sh │ ├── hostname.sh │ ├── ip_address.sh │ ├── platform.sh │ └── root_free.sh ├── systemd │ └── README.md └── windows │ └── README.md ├── go.mod ├── go.sum ├── main.go ├── media ├── ha-device.png └── ha-lovelace-big.png ├── mqtt ├── connection.go ├── payloads.go └── publisher.go ├── releaseinfo └── releaseinfo.go ├── sensors └── sensors.go ├── settings └── settings.go ├── utility ├── fileutil.go └── uuid.go └── web └── api ├── api.go ├── apiendpoints.go ├── apierrors.go └── apiserver.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Project specific files 18 | dist/* 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .EXPORT_ALL_VARIABLES: 2 | VERSION = 0.2.0 3 | BUILDDATE = $$(date) 4 | LASTCOMMIT = $$(git rev-parse --short HEAD) 5 | 6 | build: test compile upx 7 | echo "Successfully built Sensible." 8 | 9 | build-noupx: test compile 10 | echo "Successfully built Sensible." 11 | 12 | docker-example: 13 | mkdir -p dist/docker-build/etc/sensible dist/docker-build/log 14 | cp dist/sensible dist/docker-build/ 15 | cp -R examples/docker/* dist/docker-build 16 | cp -R examples/scripts dist/docker-build/etc/sensible 17 | $(SHELL) -c "cd dist/docker-build;docker build -t thetinkerdad/sensible-nginx-test .;cd -" 18 | 19 | test: 20 | go test 21 | 22 | compile: 23 | # go build -ldflags="-w" -o dist/sensible 24 | CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -X 'TheTinkerDad/sensible/releaseinfo.LastCommit=$(LASTCOMMIT)' -X 'TheTinkerDad/sensible/releaseinfo.BuildTime=$(BUILDDATE)' -X 'TheTinkerDad/sensible/releaseinfo.Version=$(VERSION)'" -a -installsuffix cgo -o dist/sensible 25 | 26 | upx: 27 | upx -9 dist/sensible 28 | 29 | run: 30 | $(SHELL) -c "cd dist;./sensible" 31 | 32 | release-linux-amd64: build 33 | $(SHELL) -c "cd dist;tar cvzf sensible-linux-amd64-$(VERSION).tar.gz sensible" 34 | 35 | release-rpi-armhf: build 36 | $(SHELL) -c "cd dist;tar cvzf sensible-rpi-armhf-$(VERSION).tar.gz sensible" 37 | 38 | release-rpi-arm64: build 39 | $(SHELL) -c "cd dist;tar cvzf sensible-rpi-arm64-$(VERSION).tar.gz sensible" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is Sensible? 2 | A small tool that provides monitoring for your Linux server via Home Assistant sensors and MQTT discovery. 3 | 4 | By default Sensible comes with only a few example sensors, but it is basically a framework that enables you to quickly prototype and implement your own sensors. 5 | 6 | The below ongoing video series showcase Sensible and its capabilities along with development news, updates and the feature roadmap. 7 | 8 | [![Introduction to Sensible](https://img.youtube.com/vi/21pho997KuA/0.jpg)](https://www.youtube.com/watch?v=21pho997KuA) 9 | 10 | [![The first update release of Sensible](https://img.youtube.com/vi/fdOiT78yFxc/0.jpg)](https://www.youtube.com/watch?v=fdOiT78yFxc) 11 | 12 | # Why should you use Sensible? 13 | 14 | * It's tiny! Currently the binary is approximately 2.4Mb in size! You can put it into a Docker container and you won't even notice it's there! 15 | 16 | * Thanks to MQTT discovery, its integration with Home Assistant is as smooth as possible. 17 | 18 | ![Sensible as a device in Home Assistant](media/ha-device.png?raw=true "Sensible's MQTT based integration in Home Assistant") 19 | 20 | * Because it follows basic MQTT / Home Assistant standards, it's easy to use with things like Lovelace UI, Node Red, you name it! 21 | 22 | ![Sensible sensors on the Lovelace UI](media/ha-lovelace-big.png?raw=true "Sensible's example sensors on the Lovelace UI") 23 | 24 | * It's fully opensource with a permissive license! You can fork it on GitHub and make your own version! 25 | 26 | * It has a control REST API that enables disabling sensor data publishing, etc. (still WIP though) 27 | 28 | * The developer behind is a veteran with 20+ years of experience, so the project is here to stay, you can expect support and future updates! 29 | 30 | *Note*: Yes, it's probably a temporary name, but I wanted to have something that at least a bit makes sense... (Pun intended!) 31 | 32 | # How it works? 33 | 34 | Sensible is currently a framework application that works with Home Assistant and MQTT discovery. 35 | You can configure sensors as plugins for the framework and the sensors will appear in Home Assistant. 36 | There are currently two ways to implement sensors, although this part is still under development. 37 | First, you can code them in Golang and build them as part of Sensible. 38 | Second, you can implement them as unix shell scripts. In this case, you don't need to build Sensible, but you can use a prebuilt binary. 39 | 40 | # Quickstart guide 41 | 42 | - Currently only Linux is supported - if you're running other OSes, sorry, you'll have to wait! 43 | 44 | - Grab one of the releases from https://github.com/TheTinkerDad/sensible/releases or build Sensible on your own (see below) 45 | 46 | - The .tar.gz file only contains the binary, extract it somewhere convenient. 47 | 48 | - Run it the first time with "./sensible -r" and it'll generate the default config file: /etc/sensible/settings.yaml and the required folders 49 | 50 | - Edit the config file to customize your settings 51 | 52 | - Scripts should be located under /etc/sensible/scripts (or in the folder you've configured in the settings.yaml file) 53 | 54 | - You can find the example scripts [here](examples/scripts) or you can start by making your own, they are rather simple 55 | 56 | - Add a sensor entry in the config file for each of your scripts like: 57 | ``` 58 | - name: Sensible Host IP Address 59 | kind: script 60 | sensorid: ip_address 61 | script: ip_address.sh 62 | unitofmeasurement: "" 63 | icon: mdi:check-network 64 | ``` 65 | 66 | # Removing sensors from Home Assistant 67 | 68 | If you need to change your Sensible sensors in the configuration.yaml file in a way that it will break entities in Home Assistant, it is highly advised to remove all the sensors from Home Assistant 69 | 70 | # Building Sensible 71 | 72 | ## Requirements 73 | 74 | - Golang 1.14 or newer 75 | - GNU Make 76 | - UPX (Universal Packer for eXecutables) - this one is optional though 77 | 78 | ## Build using make 79 | 80 | Sensible is currently being built for Linux, using make: 81 | 82 | This one builds the executable and packs it with UPX 83 | ``` 84 | make build 85 | ``` 86 | 87 | Also builds the executable, but without applying UPX: 88 | ``` 89 | make build-noupx 90 | ``` 91 | 92 | It is also possible to build example code for Docker, etc - see the Example usage section for this. 93 | 94 | # Configuration 95 | 96 | This is currently done via a the file /etc/sensible/settings.yaml 97 | 98 | A sample file looks like this one below - if you start Sensible for the first time with the "-r" command line parameter, the very same file will be generated for you. 99 | 100 | ``` 101 | general: 102 | logfile: /var/log/sensible/sensible.log 103 | loglevel: info 104 | scriptlocation: /etc/sensible/scripts/ 105 | mqtt: 106 | hostname: 127.0.0.1 107 | port: "1883" 108 | username: "" 109 | password: "" 110 | clientid: sensible_mqtt_client 111 | discovery: 112 | devicename: sensible-demo 113 | prefix: homeassistant 114 | plugins: 115 | - name: Heartbeat 116 | kind: internal 117 | sensorid: heartbeat 118 | script: "" 119 | unitofmeasurement: "" 120 | icon: mdi:wrench-check 121 | - name: Boot Time 122 | kind: internal 123 | sensorid: boot_time 124 | script: "" 125 | unitofmeasurement: "" 126 | icon: mdi:clock 127 | - name: System Time 128 | kind: internal 129 | sensorid: system_time 130 | script: "" 131 | unitofmeasurement: "" 132 | icon: mdi:clock 133 | - name: Root Disk Free 134 | kind: script 135 | sensorid: root_free 136 | script: root_free.sh 137 | unitofmeasurement: GB 138 | icon: mdi:harddisk 139 | - name: Host IP Address 140 | kind: script 141 | sensorid: ip_address 142 | script: ip_address.sh 143 | unitofmeasurement: "" 144 | icon: mdi:check-network 145 | - name: Hostname 146 | kind: script 147 | sensorid: hostname 148 | script: hostname.sh 149 | unitofmeasurement: "" 150 | icon: mdi:network 151 | - name: Platform 152 | kind: script 153 | sensorid: platform 154 | script: platform.sh 155 | unitofmeasurement: "" 156 | icon: mdi:wrench-check 157 | ``` 158 | 159 | # Example scripts 160 | 161 | There are a couple of scripts under the examples/scripts folders that are also configured to act as sensors in the above example configuration file. 162 | 163 | The only requirement for these scripts is that they should be simple, with an execution time no longer than 1-2 seconds and they should only output the value that is meant to be a sensor value. E.g. the ip_address.sh script only outputs an IP / CIDR. 164 | 165 | # Example usage 166 | 167 | * [A standalone systemd service on Linux servers](examples/systemd/README.md) 168 | * [Plugged into Docker containers](examples/docker/README.md) as a background process 169 | * [Plugged into LXC/LXD containers](examples/lxc/README.md) as a service 170 | * [As a standalone service on Raspberry Pi's](examples/raspberry-pi/README.md) 171 | * [As a system service on Windows](examples/windows/README.md) 172 | 173 | # Development and planned features 174 | 175 | * Security! MQTT encryption and all the bells and whistles to make it production ready ASAP! 176 | * There should be a way to implement sensors in Go for fully customized sensor data (plugin architecture) without rebuilding Sensible itself 177 | * Documentation for the REST API 178 | * A way to control Sensible via MQTT 179 | * Configuration via environment variables 180 | * A lot of small TODO items in the code 181 | 182 | -------------------------------------------------------------------------------- /examples/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginxdemos/hello 2 | 3 | COPY sensible sensible 4 | COPY wrapper.sh wrapper.sh 5 | RUN chmod +x /wrapper.sh 6 | 7 | EXPOSE 8090 8 | 9 | CMD . ./wrapper.sh -------------------------------------------------------------------------------- /examples/docker/README.md: -------------------------------------------------------------------------------- 1 | # Plugging Sensible into your Docker containers 2 | 3 | ## Currently tested container OSes 4 | 5 | * Ubuntu 18.x --> 20.x 6 | * Debian 10.x --> 11.x 7 | * Alpine 3.14 --> 3.17 8 | 9 | ## Requirements for building: 10 | 11 | * Docker 12 | * Optionally, Docker Compose 13 | 14 | ## Steps 15 | 16 | TBD 17 | 18 | -------------------------------------------------------------------------------- /examples/docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "2.0" 2 | 3 | services: 4 | nginx-test: 5 | image: thetinkerdad/sensible-nginx-test 6 | ports: 7 | - 90:80 8 | - 8090:8090 9 | volumes: 10 | - ./log:/var/log/sensible 11 | - ./etc/sensible:/etc/sensible 12 | -------------------------------------------------------------------------------- /examples/docker/wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Start Sensible as a background process 4 | ./sensible & 5 | # Start the original process of your container (replace this as needed) 6 | nginx -g 'daemon off;' -------------------------------------------------------------------------------- /examples/lxc/README.md: -------------------------------------------------------------------------------- 1 | # Plugging Sensible into your LXC containers 2 | 3 | ## Currently tested container OSes 4 | 5 | * Ubuntu 18.x --> 20.x 6 | * Debian 10.x --> 11.x 7 | * Alpine 3.14 --> 3.17 8 | 9 | ## Steps 10 | 11 | TBD 12 | 13 | -------------------------------------------------------------------------------- /examples/raspberry-pi/README.md: -------------------------------------------------------------------------------- 1 | # Plugging Sensible into your Docker containers 2 | 3 | ## Currently tested OSes 4 | 5 | * Raspbian --> to be tested 6 | * Ubuntu --> to be tested 7 | 8 | ## Requirements for building: 9 | 10 | * TBD 11 | 12 | ## Steps 13 | 14 | TBD 15 | 16 | -------------------------------------------------------------------------------- /examples/scripts/cpu_temp.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cat /sys/class/thermal/thermal_zone0/temp | awk '{printf "%.2f", $0 / 1000}' -------------------------------------------------------------------------------- /examples/scripts/hostname.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | hostname -f 4 | -------------------------------------------------------------------------------- /examples/scripts/ip_address.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ip a s eth0 | awk '/inet / {print$2}' -------------------------------------------------------------------------------- /examples/scripts/platform.sh: -------------------------------------------------------------------------------- 1 | uname --hardware-platform -------------------------------------------------------------------------------- /examples/scripts/root_free.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | df / | tail -n 1 | awk '/ / {printf "%.2f", $4 / 1048576}' 4 | -------------------------------------------------------------------------------- /examples/systemd/README.md: -------------------------------------------------------------------------------- 1 | # Running Sensible as a standalone systemd service on Linux servers 2 | 3 | ## Currently tested OSes 4 | 5 | * Ubuntu 18.x --> 20.x 6 | * Debian 10.x --> 11.x 7 | 8 | ## Steps 9 | 10 | ### 1. Preparation 11 | You need download and unpack/build Sensible binary program 12 | 13 | ### 2. Create user and basic structure of files 14 | ```shell 15 | mv sensible /usr/local/bin/sensible 16 | chmod a+x /usr/local/bin/sensible 17 | useradd sensible 18 | mkdir /var/log/sensible 19 | chown sensible /var/log/sensible 20 | mkdir -p /etc/sensible/scripts 21 | ``` 22 | 23 | ### 3. Create file `/etc/sensible/settings.yaml` 24 | 25 | You can use "sensible -r" to create the needed folders and the example settings.yaml file. 26 | 27 | This will create /etc/sensible, /etc/sensible/scripts and /etc/sensible/settings.yaml 28 | 29 | ### 4. Give access to the created files to Sensible 30 | 31 | ```shell 32 | chown -R sensible /etc/sensible 33 | ``` 34 | 35 | ### 5. Add your scripts 36 | 37 | ```shell 38 | cp /etc/sensible/scripts/ 39 | chown -R sensible /etc/sensible/scripts 40 | ``` 41 | 42 | ### 6. Create and start service 43 | 44 | create file `/lib/systemd/system/sensible.service` 45 | with contents 46 | 47 | ```ini 48 | [Unit] 49 | Description=Sensible monitoring service 50 | After=network.target 51 | 52 | [Service] 53 | ExecStart=/usr/local/bin/sensible 54 | User=sensible 55 | Restart=on-failure 56 | 57 | [Install] 58 | WantedBy=multi-user.target 59 | 60 | ``` 61 | 62 | Activate service 63 | 64 | ```shell 65 | systemctl daemon-reload 66 | systemctl enable sensible.service 67 | systemctl start sensible.service 68 | ``` 69 | 70 | Check status of service 71 | 72 | ```shell 73 | systemctl status sensible.service 74 | ``` -------------------------------------------------------------------------------- /examples/windows/README.md: -------------------------------------------------------------------------------- 1 | # Running Sensible as a system service on Windows 2 | 3 | Support for Windows is a feature planned for later versions. 4 | 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module TheTinkerDad/sensible 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/TheTinkerDad/go.pipe v1.0.2 7 | github.com/eclipse/paho.mqtt.golang v1.4.1 8 | github.com/google/uuid v1.3.0 9 | github.com/sirupsen/logrus v1.9.0 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/TheTinkerDad/go.pipe v1.0.2 h1:sPug5N0JyhCY0eL4y5sSOfLOz4G0UJNXcI4U2jA43fY= 2 | github.com/TheTinkerDad/go.pipe v1.0.2/go.mod h1:70aPZ77+gsz6DJH2fYQ/XKZ//l/p0JjZgrF/j+qjVPk= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/eclipse/paho.mqtt.golang v1.4.1 h1:tUSpviiL5G3P9SZZJPC4ZULZJsxQKXxfENpMvdbAXAI= 7 | github.com/eclipse/paho.mqtt.golang v1.4.1/go.mod h1:JGt0RsEwEX+Xa/agj90YJ9d9DH2b7upDZMK9HRbFvCA= 8 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 9 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 10 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 11 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 15 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 16 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 17 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 18 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 19 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 20 | golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U= 21 | golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 22 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 23 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 24 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 25 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 26 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= 27 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 28 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 31 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 33 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 34 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "sync" 8 | 9 | "TheTinkerDad/sensible/mqtt" 10 | "TheTinkerDad/sensible/releaseinfo" 11 | "TheTinkerDad/sensible/sensors" 12 | "TheTinkerDad/sensible/settings" 13 | "TheTinkerDad/sensible/web/api" 14 | 15 | "github.com/sirupsen/logrus" 16 | log "github.com/sirupsen/logrus" 17 | ) 18 | 19 | func setLogLevel(level string) { 20 | 21 | switch level { 22 | case "error": 23 | log.SetLevel(logrus.ErrorLevel) 24 | case "warning": 25 | log.SetLevel(logrus.WarnLevel) 26 | case "info": 27 | log.SetLevel(logrus.InfoLevel) 28 | case "debug": 29 | log.SetLevel(logrus.DebugLevel) 30 | case "trace": 31 | log.SetLevel(logrus.TraceLevel) 32 | default: 33 | log.SetLevel(logrus.InfoLevel) 34 | } 35 | } 36 | 37 | func bootstrap() { 38 | 39 | log.Infof("Bootstrapping Sensible v%s (%s, Commit: %s)", releaseinfo.Version, releaseinfo.BuildTime, releaseinfo.LastCommit) 40 | settings.Initialize() 41 | 42 | setLogLevel(settings.All.General.LogLevel) 43 | 44 | f, err := os.OpenFile(settings.All.General.Logfile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 45 | if err != nil { 46 | log.Info("Error opening log file - logging will continue on standard output!") 47 | log.Infof("Error details: %v", err) 48 | } else { 49 | log.SetOutput(f) 50 | } 51 | 52 | mqtt.Initialize() 53 | } 54 | 55 | func execute() { 56 | 57 | funcWaitGroup := &sync.WaitGroup{} 58 | 59 | if settings.All.Api.Enabled { 60 | funcWaitGroup.Add(1) 61 | api.StartApiServer(funcWaitGroup) 62 | } 63 | 64 | funcWaitGroup.Add(1) 65 | sensors.StartProcessing(funcWaitGroup) 66 | 67 | funcWaitGroup.Wait() 68 | } 69 | 70 | func main() { 71 | 72 | log.SetOutput(os.Stdout) 73 | log.SetFormatter(&log.TextFormatter{ 74 | DisableColors: true, 75 | DisableQuote: true, 76 | FullTimestamp: true, 77 | }) 78 | 79 | var pversion, phelp, preset, unregister bool 80 | 81 | flag.BoolVar(&pversion, "v", false, "Show version info.") 82 | flag.BoolVar(&phelp, "h", false, "Show command line options.") 83 | flag.BoolVar(&preset, "r", false, "Reset settings or initialize a fresh install.") 84 | flag.BoolVar(&unregister, "u", false, "Unregister all sensors from Home Assistant via MQTT.") 85 | flag.Parse() 86 | 87 | if phelp { 88 | flag.PrintDefaults() 89 | } else if pversion { 90 | fmt.Printf("Sensible v%s (%s, Commit: %s)\n", releaseinfo.Version, releaseinfo.BuildTime, releaseinfo.LastCommit) 91 | } else if preset { 92 | log.Info("Setting up defaults...") 93 | settings.CreateFolders() 94 | settings.BackupSettingsFile() 95 | settings.GenerateDefaults() 96 | } else if unregister { 97 | bootstrap() 98 | log.Info("Unregistering all sensors...") 99 | sensors.UnregisterAllSensors() 100 | } else { 101 | bootstrap() 102 | execute() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /media/ha-device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheTinkerDad/sensible/efad618468fd1c40e760b7e7cbc08686a20deadd/media/ha-device.png -------------------------------------------------------------------------------- /media/ha-lovelace-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheTinkerDad/sensible/efad618468fd1c40e760b7e7cbc08686a20deadd/media/ha-lovelace-big.png -------------------------------------------------------------------------------- /mqtt/connection.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | import ( 4 | "TheTinkerDad/sensible/settings" 5 | "fmt" 6 | "time" 7 | 8 | mqtt "github.com/eclipse/paho.mqtt.golang" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | var MqttClient mqtt.Client 13 | 14 | var messagePubHandler mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) { 15 | log.Debugf("Received message: %s from topic: %s", msg.Payload(), msg.Topic()) 16 | } 17 | 18 | var connectHandler mqtt.OnConnectHandler = func(client mqtt.Client) { 19 | log.Info("Connected") 20 | } 21 | 22 | var connectLostHandler mqtt.ConnectionLostHandler = func(client mqtt.Client, err error) { 23 | log.Warnf("Connect lost: %v", err) 24 | } 25 | 26 | // Initialize Checks if the MQTT connection is intact 27 | func Initialize() { 28 | 29 | log.Infof("Connecting to MQTT broker at %s:%s...", settings.All.Mqtt.Hostname, settings.All.Mqtt.Port) 30 | opts := mqtt.NewClientOptions() 31 | opts.AddBroker(fmt.Sprintf("tcp://%s:%s", settings.All.Mqtt.Hostname, settings.All.Mqtt.Port)) 32 | opts.SetClientID(settings.All.Mqtt.ClientId) 33 | opts.SetUsername(settings.All.Mqtt.Username) 34 | opts.SetPassword(settings.All.Mqtt.Password) 35 | opts.SetDefaultPublishHandler(messagePubHandler) 36 | opts.OnConnect = connectHandler 37 | opts.OnConnectionLost = connectLostHandler 38 | opts.SetAutoReconnect(true) 39 | opts.SetMaxReconnectInterval(10 * time.Second) 40 | opts.SetWill(settings.All.Discovery.Prefix+"/sensor/"+settings.All.Discovery.DeviceName+"/availability", "Offline", 1, false) 41 | MqttClient = mqtt.NewClient(opts) 42 | if token := MqttClient.Connect(); token.Wait() && token.Error() != nil { 43 | panic(token.Error()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /mqtt/payloads.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | type DeviceMetadata struct { 4 | Identifiers []string `json:"identifiers,omitempty"` 5 | Manufacturer string `json:"manufacturer,omitempty"` 6 | Model string `json:"model,omitempty"` 7 | Name string `json:"name,omitempty"` 8 | } 9 | 10 | type DeviceRegistration struct { 11 | Name string `json:"name,omitempty"` 12 | DeviceClass string `json:"device_class,omitempty"` 13 | Icon string `json:"icon,omitempty"` 14 | StateTopic string `json:"state_topic,omitempty"` 15 | AvailabilityTopic string `json:"availability_topic,omitempty"` 16 | PayloadAvailable string `json:"payload_available,omitempty"` 17 | PayloadNotAvailable string `json:"payload_not_available,omitempty"` 18 | UnitOfMeasurement string `json:"unit_of_measurement,omitempty"` 19 | ValueTemplate string `json:"value_template,omitempty"` 20 | UniqueId string `json:"unique_id,omitempty"` 21 | Device DeviceMetadata `json:"device,omitempty"` 22 | } 23 | 24 | type DeviceRemoval struct { 25 | } 26 | -------------------------------------------------------------------------------- /mqtt/publisher.go: -------------------------------------------------------------------------------- 1 | package mqtt 2 | 3 | import ( 4 | "TheTinkerDad/sensible/settings" 5 | "encoding/json" 6 | "fmt" 7 | 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | var Paused bool 12 | 13 | func RegisterSensor(device DeviceRegistration) { 14 | 15 | payload, err := json.Marshal(device) 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | log.Debugf("Registering new sensor with ID %s: %s", device.UniqueId, payload) 20 | topic := fmt.Sprintf("%s/sensor/%s/%s/config", settings.All.Discovery.Prefix, settings.All.Discovery.DeviceName, device.UniqueId) 21 | log.Debugf("Configuration topic: %s", topic) 22 | if token := MqttClient.Publish(topic, 1, true, payload); token.Wait() && token.Error() != nil { 23 | panic(token.Error()) 24 | } 25 | } 26 | 27 | func RemoveSensor(id string) { 28 | 29 | log.Debugf("Unregistering sensor with ID %s", id) 30 | topic := fmt.Sprintf("%s/sensor/%s/%s/config", settings.All.Discovery.Prefix, settings.All.Discovery.DeviceName, settings.All.Discovery.DeviceName+"_"+id) 31 | log.Debugf("Configuration topic: %s", topic) 32 | if token := MqttClient.Publish(topic, 1, true, ""); token.Wait() && token.Error() != nil { 33 | panic(token.Error()) 34 | } 35 | } 36 | 37 | func SendSensorValue(id string, value string) { 38 | 39 | log.Tracef("Sending sensor value for sensor with ID %s: %s", settings.All.Discovery.DeviceName+"_"+id, value) 40 | topic := fmt.Sprintf("%s/sensor/%s/%s/state", settings.All.Discovery.Prefix, settings.All.Discovery.DeviceName, settings.All.Discovery.DeviceName+"_"+id) 41 | log.Tracef("State topic: %s", topic) 42 | MqttClient.Publish(topic, 1, false, value) 43 | } 44 | 45 | func SendDeviceAvailability(value string) { 46 | 47 | topic := fmt.Sprintf("%s/sensor/%s/availability", settings.All.Discovery.Prefix, settings.All.Discovery.DeviceName) 48 | log.Tracef("Sending availability info for device with name %s: %s. Topic: %s", settings.All.Discovery.DeviceName, value, topic) 49 | MqttClient.Publish(topic, 1, false, value) 50 | } 51 | 52 | func SendAlwaysAvailableMessage() { 53 | 54 | topic := fmt.Sprintf("%s/sensor/%s/always-available", settings.All.Discovery.Prefix, settings.All.Discovery.DeviceName) 55 | log.Tracef("Sending 'always available' info for device with name %s. Topic: %s", settings.All.Discovery.DeviceName, topic) 56 | MqttClient.Publish(topic, 1, false, "Online") 57 | } 58 | -------------------------------------------------------------------------------- /releaseinfo/releaseinfo.go: -------------------------------------------------------------------------------- 1 | package releaseinfo 2 | 3 | var ( 4 | LastCommit string 5 | Version string 6 | BuildTime string 7 | ) 8 | -------------------------------------------------------------------------------- /sensors/sensors.go: -------------------------------------------------------------------------------- 1 | package sensors 2 | 3 | import ( 4 | "TheTinkerDad/sensible/mqtt" 5 | "TheTinkerDad/sensible/settings" 6 | "bytes" 7 | "fmt" 8 | "os/exec" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | pipe "github.com/TheTinkerDad/go.pipe" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | func getDeviceMetaData() mqtt.DeviceMetadata { 18 | 19 | dmd := mqtt.DeviceMetadata{ 20 | Name: settings.All.Discovery.DeviceName, 21 | Manufacturer: "TheTinkerDad", 22 | Model: "Sensible-Sensor", 23 | } 24 | dmd.Identifiers = make([]string, 1) 25 | dmd.Identifiers[0] = ("sensible-" + settings.All.Discovery.DeviceName) 26 | return dmd 27 | } 28 | 29 | func getSensorMetaData(id string, name string, icon string, unit string) mqtt.DeviceRegistration { 30 | 31 | var deviceClass, stateTopic, availabilityTopic string 32 | if id == "heartbeat" { 33 | stateTopic = settings.All.Discovery.Prefix + "/sensor/" + settings.All.Discovery.DeviceName + "/availability" 34 | availabilityTopic = settings.All.Discovery.Prefix + "/sensor/" + settings.All.Discovery.DeviceName + "/always-available" 35 | } else { 36 | stateTopic = settings.All.Discovery.Prefix + "/sensor/" + settings.All.Discovery.DeviceName + "/" + settings.All.Discovery.DeviceName + "_" + id + "/state" 37 | availabilityTopic = settings.All.Discovery.Prefix + "/sensor/" + settings.All.Discovery.DeviceName + "/availability" 38 | } 39 | 40 | dr := mqtt.DeviceRegistration{ 41 | Name: fmt.Sprintf("%s %s", settings.All.Discovery.DeviceName, name), 42 | DeviceClass: deviceClass, 43 | Icon: icon, 44 | StateTopic: stateTopic, 45 | AvailabilityTopic: availabilityTopic, 46 | PayloadAvailable: "Online", 47 | PayloadNotAvailable: "Offline", 48 | UnitOfMeasurement: unit, 49 | ValueTemplate: "", 50 | //ValueTemplate: "{{value_json.value}}", 51 | UniqueId: settings.All.Discovery.DeviceName + "_" + id, 52 | Device: getDeviceMetaData(), 53 | } 54 | return dr 55 | } 56 | 57 | // These below methods are for updating the simple internal sensors we currently have 58 | 59 | func updateSensorSystemTime() { 60 | 61 | now := time.Now() 62 | mqtt.SendSensorValue("system_time", string(now.Format("2006-01-02 15:04:05"))) 63 | } 64 | 65 | func updateSensorBootTime() { 66 | 67 | // TODO: Find an OS-agnostic solution for this! 68 | out, err := exec.Command("uptime", "-s").Output() 69 | if err != nil { 70 | log.Warn(err) 71 | out = []byte("Unavailable") 72 | } 73 | value := strings.TrimSuffix(string(out), "\n") 74 | mqtt.SendSensorValue("boot_time", value) 75 | } 76 | 77 | // This updates sensors based on scripts 78 | 79 | func updateSensorWithScript(p settings.Plugin) { 80 | 81 | log.Tracef("Executing %s%s", settings.All.General.ScriptLocation, p.Script) 82 | // Using pipe here looks like an overkill, but can be useful later... 83 | var b bytes.Buffer 84 | if err := pipe.Command(&b, 85 | exec.Command("sh", "-c", settings.All.General.ScriptLocation+p.Script), 86 | ); err != nil { 87 | log.Warn(err) 88 | } 89 | value := strings.TrimSuffix(b.String(), "\n") 90 | mqtt.SendSensorValue(p.SensorId, value) 91 | } 92 | 93 | var SensorUpdater chan string 94 | 95 | // UnregisterAllSensors Sends MQTT messages to HA to deregister all sensors 96 | func UnregisterAllSensors() { 97 | 98 | for _, p := range settings.All.Plugins { 99 | mqtt.RemoveSensor(p.SensorId) 100 | } 101 | } 102 | 103 | // StartProcessing Starts the loop to process and send sensor data 104 | func StartProcessing(wg *sync.WaitGroup) { 105 | 106 | go func() { 107 | 108 | defer wg.Done() 109 | 110 | for _, p := range settings.All.Plugins { 111 | mqtt.RegisterSensor(getSensorMetaData(p.SensorId, p.Name, p.Icon, p.UnitOfMeasurement)) 112 | } 113 | 114 | log.Info("Entering MQTT message processing loop...") 115 | 116 | for { 117 | if !mqtt.Paused { 118 | select { 119 | case msg := <-SensorUpdater: 120 | log.Tracef("Received message %s", msg) 121 | default: 122 | mqtt.SendAlwaysAvailableMessage() 123 | mqtt.SendDeviceAvailability("Online") 124 | for _, p := range settings.All.Plugins { 125 | switch p.Kind { 126 | case "internal": 127 | //TODO: This should be reflection based! 128 | switch p.SensorId { 129 | case "boot_time": 130 | updateSensorBootTime() 131 | case "system_time": 132 | updateSensorSystemTime() 133 | default: 134 | } 135 | case "script": 136 | updateSensorWithScript(p) 137 | default: 138 | } 139 | } 140 | } 141 | } 142 | // TODO: This sould be removed and update periodicity should be configurable on a per-sensor basis 143 | time.Sleep(10 * time.Second) 144 | } 145 | }() 146 | } 147 | -------------------------------------------------------------------------------- /settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "TheTinkerDad/sensible/utility" 5 | "errors" 6 | "os" 7 | 8 | log "github.com/sirupsen/logrus" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | type GeneralSettings struct { 13 | Logfile string 14 | LogLevel string 15 | ScriptLocation string 16 | } 17 | 18 | type MqttSettings struct { 19 | Hostname string 20 | Port string 21 | Username string 22 | Password string 23 | ClientId string 24 | } 25 | 26 | type DiscoverySettings struct { 27 | DeviceName string 28 | Prefix string 29 | } 30 | 31 | type ApiSettings struct { 32 | Enabled bool 33 | Port int 34 | Token string 35 | } 36 | 37 | type AllSettings struct { 38 | General GeneralSettings 39 | Mqtt MqttSettings 40 | Discovery DiscoverySettings 41 | Api ApiSettings 42 | Plugins []Plugin 43 | } 44 | 45 | type Plugin struct { 46 | Name string 47 | Kind string 48 | SensorId string 49 | Script string 50 | UnitOfMeasurement string 51 | Icon string 52 | } 53 | 54 | var All AllSettings 55 | 56 | var settingsFile string = "/etc/sensible/settings.yaml" 57 | 58 | // Backs up the existing settings file - if there's any 59 | func BackupSettingsFile() { 60 | 61 | if _, err := os.Stat(settingsFile); errors.Is(err, os.ErrNotExist) { 62 | return 63 | } else { 64 | utility.Copy(settingsFile, settingsFile+".bkp") 65 | } 66 | } 67 | 68 | // Generates the default configuration file 69 | func GenerateDefaults() { 70 | 71 | All.General = GeneralSettings{"/var/log/sensible/sensible.log", "info", "/etc/sensible/scripts/"} 72 | All.Mqtt = MqttSettings{"127.0.0.1", "1883", "", "", "sensible_mqtt_client"} 73 | All.Discovery = DiscoverySettings{"sensible-demo", "homeassistant"} 74 | All.Api = ApiSettings{Port: 8090, Enabled: false, Token: utility.NewRandomUUID()} 75 | All.Plugins = make([]Plugin, 7) 76 | All.Plugins[0] = Plugin{"Heartbeat", "internal", "heartbeat", "", "", "mdi:wrench-check"} 77 | All.Plugins[1] = Plugin{"Boot Time", "internal", "boot_time", "", "", "mdi:clock"} 78 | All.Plugins[2] = Plugin{"System Time", "internal", "system_time", "", "", "mdi:clock"} 79 | All.Plugins[3] = Plugin{"Root Disk Free", "script", "root_free", "root_free.sh", "GB", "mdi:harddisk"} 80 | All.Plugins[4] = Plugin{"Host IP Address", "script", "ip_address", "ip_address.sh", "", "mdi:network"} 81 | All.Plugins[5] = Plugin{"Hostname", "script", "hostname", "hostname.sh", "", "mdi:network"} 82 | All.Plugins[6] = Plugin{"Platform", "script", "platform", "platform.sh", "", "mdi:wrench-check"} 83 | 84 | yaml, err := yaml.Marshal(&All) 85 | if err != nil { 86 | log.Fatal(err) 87 | } 88 | 89 | f, err2 := os.Create(settingsFile) 90 | if err2 != nil { 91 | log.Fatal(err) 92 | } 93 | _, err2 = f.Write(yaml) 94 | if err2 != nil { 95 | log.Fatal(err) 96 | } 97 | f.Close() 98 | } 99 | 100 | // CreateFolders Creates the default folders used by Sensible 101 | func CreateFolders() { 102 | 103 | log.Info("Creating default folders...") 104 | utility.CreateFolder("/etc/sensible/scripts/") 105 | utility.CreateFolder("/var/log/sensible") 106 | } 107 | 108 | // GenerateDefaultIfNotExists Generates the default configuration file 109 | func GenerateDefaultIfNotExists() { 110 | 111 | if _, err := os.Stat(settingsFile); errors.Is(err, os.ErrNotExist) { 112 | 113 | log.Warn("Config file not found, writing default config...") 114 | GenerateDefaults() 115 | } 116 | } 117 | 118 | // Load Loads the current settings 119 | func Load() { 120 | 121 | f, err := os.Open(settingsFile) 122 | if err != nil { 123 | log.Fatal(err) 124 | } 125 | defer f.Close() 126 | 127 | fi, _ := f.Stat() 128 | raw := make([]byte, fi.Size()) 129 | f.Read(raw) 130 | 131 | err = yaml.Unmarshal(raw, &All) 132 | if err != nil { 133 | log.Fatal(err) 134 | } 135 | } 136 | 137 | // Initialize Tries to load the current settings - initializes a base settings file if there's none available 138 | func Initialize() { 139 | 140 | log.Debug("Opening configuration file...") 141 | GenerateDefaultIfNotExists() 142 | Load() 143 | } 144 | -------------------------------------------------------------------------------- /utility/fileutil.go: -------------------------------------------------------------------------------- 1 | package utility 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/fs" 7 | "os" 8 | 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // Copy copies a file to another location 13 | func Copy(src, dst string) (int64, error) { 14 | 15 | srcFile, err := os.Stat(src) 16 | if err != nil { 17 | return 0, err 18 | } 19 | 20 | if !srcFile.Mode().IsRegular() { 21 | return 0, fmt.Errorf("%s is not a regular file", src) 22 | } 23 | 24 | source, err := os.Open(src) 25 | if err != nil { 26 | return 0, err 27 | } 28 | defer source.Close() 29 | 30 | destination, err := os.Create(dst) 31 | if err != nil { 32 | return 0, err 33 | } 34 | defer destination.Close() 35 | 36 | copiedBytes, err := io.Copy(destination, source) 37 | return copiedBytes, err 38 | } 39 | 40 | // CreateFolder Create a folder and check if creation was successful 41 | func CreateFolder(path string) { 42 | err := os.MkdirAll(path, fs.ModeDir) 43 | if err != nil { 44 | log.Panicf("Couldn't create folder %s!", path) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /utility/uuid.go: -------------------------------------------------------------------------------- 1 | package utility 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | func NewRandomUUID() string { 8 | return uuid.NewString() 9 | } 10 | -------------------------------------------------------------------------------- /web/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type Body struct { 4 | Result string 5 | } 6 | 7 | // SimpleApiResult Holds data about the result of a simple operation 8 | type SimpleApiResult struct { 9 | Result Body 10 | Status int 11 | } 12 | -------------------------------------------------------------------------------- /web/api/apiendpoints.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "TheTinkerDad/sensible/mqtt" 5 | "TheTinkerDad/sensible/releaseinfo" 6 | "context" 7 | "fmt" 8 | "net/http" 9 | 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // Provides some minimal info about the process 14 | func DoInfo() SimpleApiResult { 15 | 16 | return SimpleApiResult{Result: Body{Result: fmt.Sprintf("Sensible v%s Running...", releaseinfo.Version)}, Status: http.StatusOK} 17 | } 18 | 19 | // Shuts down the Sensible server 20 | func DoShutdown(Server *http.Server) SimpleApiResult { 21 | 22 | log.Info("Shutting down...") 23 | Server.Shutdown(context.TODO()) 24 | return SimpleApiResult{Result: Body{Result: "OK"}, Status: http.StatusOK} 25 | } 26 | 27 | // Pauses MQTT sensor updates 28 | func DoPauseMqtt() SimpleApiResult { 29 | 30 | log.Info("MQTT Sensor updates paused.") 31 | mqtt.Paused = true 32 | return SimpleApiResult{Result: Body{Result: "OK"}, Status: http.StatusOK} 33 | } 34 | 35 | // Resumes MQTT sensor updates 36 | func DoResumeMqtt() SimpleApiResult { 37 | 38 | log.Info("MQTT Sensor updates resumed.") 39 | mqtt.Paused = false 40 | return SimpleApiResult{Result: Body{Result: "OK"}, Status: http.StatusOK} 41 | } 42 | -------------------------------------------------------------------------------- /web/api/apierrors.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "net/http" 4 | 5 | // Returns an error explaining that a security token is missing 6 | func ErrMissingToken() SimpleApiResult { 7 | 8 | return SimpleApiResult{Result: Body{Result: "Security token missing!"}, Status: http.StatusUnauthorized} 9 | } 10 | 11 | // Returns an error explaining that a security token is wrong 12 | func ErrWrongToken() SimpleApiResult { 13 | 14 | return SimpleApiResult{Result: Body{Result: "Security token is wrong, please check either the URL or your settings!"}, Status: http.StatusUnauthorized} 15 | } 16 | -------------------------------------------------------------------------------- /web/api/apiserver.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "TheTinkerDad/sensible/settings" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "sync" 9 | 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var Server *http.Server 14 | 15 | func apiHandler(w http.ResponseWriter, r *http.Request) { 16 | 17 | var result interface{} = nil 18 | 19 | result = checkToken(r, result) 20 | 21 | log.Debugf("Calling %s...", r.URL.Path) 22 | 23 | if result == nil { 24 | if r.URL.Path == "/api/info" { 25 | result = DoInfo() 26 | } else if r.URL.Path == "/api/shutdown" { 27 | result = DoShutdown(Server) 28 | } else if r.URL.Path == "/api/pause-mqtt" { 29 | result = DoPauseMqtt() 30 | } else if r.URL.Path == "/api/resume-mqtt" { 31 | result = DoResumeMqtt() 32 | } 33 | } 34 | 35 | w.Header().Set("Content-Type", "application/json") 36 | w.WriteHeader(result.(SimpleApiResult).Status) 37 | json.NewEncoder(w).Encode(result.(SimpleApiResult).Result) 38 | } 39 | 40 | func checkToken(r *http.Request, result interface{}) interface{} { 41 | if settings.All.Api.Token != "" { 42 | if r.URL.Query().Has("token") { 43 | if r.URL.Query().Get("token") != settings.All.Api.Token { 44 | result = ErrWrongToken() 45 | } 46 | } else { 47 | result = ErrMissingToken() 48 | } 49 | } 50 | return result 51 | } 52 | 53 | func StartApiServer(wg *sync.WaitGroup) { 54 | 55 | srv := &http.Server{Addr: fmt.Sprintf(":%d", settings.All.Api.Port)} 56 | http.HandleFunc("/api/", apiHandler) 57 | 58 | go func() { 59 | defer wg.Done() 60 | if err := srv.ListenAndServe(); err != http.ErrServerClosed { 61 | log.Fatalf("ListenAndServe(): %v", err) 62 | } 63 | }() 64 | 65 | log.Infof("Listening on port %d...", settings.All.Api.Port) 66 | 67 | Server = srv 68 | } 69 | --------------------------------------------------------------------------------