├── .gitignore ├── LICENSE ├── Makefile ├── RASPBIAN.md ├── README.md ├── SNAPSHOTS.md ├── Taskfile.yml ├── _img ├── assembly-camera.jpg ├── assembly-lid-beam1.jpg ├── assembly-lid-beam2.jpg ├── assembly-lid-snap.jpg ├── assembly-rpi.jpg ├── automation.jpg ├── elp-1080p.jpg ├── enclosure-desk.jpg ├── enclosure-wall.jpg ├── etcher.png ├── home-app-camera.jpeg ├── home-app-more-options.jpeg ├── home-app-pin.jpeg ├── home-app-select-camera.jpeg ├── homeplus-automation.jpeg ├── homeplus-snapshots.png ├── homeplus-stream.png ├── live-stream.jpg ├── loose-camera-cables.jpg ├── rpi-imager-os.png ├── rpi-imager-settings.png ├── rpi-imager-storage.png ├── rpi-imager-write.png ├── rpi-imager.png ├── services.jpg ├── snapshot.jpg ├── snapshots.jpg └── web-interface.png ├── ansible ├── group_vars │ └── all ├── hosts ├── roles │ ├── hkcam │ │ ├── defaults │ │ │ └── main.yml │ │ └── tasks │ │ │ ├── configure.yml │ │ │ ├── install.yml │ │ │ └── main.yml │ └── runit │ │ ├── README │ │ ├── defaults │ │ └── main.yml │ │ ├── meta │ │ └── main.yml │ │ ├── tasks │ │ ├── add.yml │ │ ├── enabled.yml │ │ ├── envs.yml │ │ ├── install.yml │ │ ├── main.yml │ │ └── state.yml │ │ └── templates │ │ ├── finish.j2 │ │ ├── log-run.j2 │ │ ├── run.j2 │ │ └── runsvdir-start.j2 └── rpi.yml ├── api ├── api.go ├── apiutil │ ├── decode.go │ └── json.go ├── error.go ├── json.go ├── snapshot.go └── system.go ├── app ├── app.go └── update.go ├── assets.go ├── camera_control.go ├── cmd └── hkcam │ └── main.go ├── delete_assets.go ├── enclosure ├── Block.STL ├── Body.step ├── Body.stl ├── Body_support.stl ├── Bolt.stl ├── Lid.step ├── Lid.stl ├── Nut.stl ├── README.md ├── Stand.step ├── Stand.stl ├── WallMount.stl ├── all.shapr ├── enclosure_gopro.stl ├── ffLink_90_support.STL ├── mfLink_90_support.STL └── mfLink_support.STL ├── ffmpeg ├── README.md ├── config.go ├── doc.go ├── ffmpeg.go ├── loopback.go ├── snapshot.go └── stream.go ├── get_asset.go ├── go.mod ├── go.sum ├── html ├── error.go ├── home.go ├── html.go ├── page.go ├── tmpl │ ├── error.tmpl │ ├── home.tmpl │ ├── layout.tmpl │ ├── min-layout.tmpl │ ├── partial │ │ ├── activity-indicator.tmpl │ │ ├── debug-alert.tmpl │ │ ├── foot.tmpl │ │ ├── footer.tmpl │ │ ├── head.tmpl │ │ ├── header.tmpl │ │ ├── message.tmpl │ │ └── update-alert.tmpl │ └── restart.tmpl └── update.go ├── setup.go ├── static ├── css │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ └── ispinner.prefixed.css └── js │ ├── api.js │ ├── bootstrap.bundle.min.js │ ├── bootstrap.bundle.min.js.map │ ├── jquery.js │ ├── sprintf.js │ └── util.js ├── take_snapshot.go └── tools.go /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .DS_Store 3 | db 4 | cmd/hkcam/fs.go -------------------------------------------------------------------------------- /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 | Copyright 2018 Matthias Hochgatterer 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOCMD=go 2 | GOBUILD=$(GOCMD) build 3 | GOCLEAN=$(GOCMD) clean 4 | GOTEST=$(GOCMD) test 5 | GORUN=$(GOCMD) run 6 | 7 | VERSION=$(shell git describe --exact-match --tags 2>/dev/null) 8 | BUILD_DIR=build 9 | PACKAGE_RPI=hkcam-$(VERSION)_linux_armhf 10 | 11 | export GO111MODULE=on 12 | 13 | build-fs: 14 | $(GORUN) github.com/mjibson/esc -o cmd/hkcam/fs.go -ignore ".*\.go" html static 15 | 16 | test: build-fs 17 | $(GOTEST) -v ./... 18 | 19 | clean: 20 | $(GOCLEAN) 21 | rm -rf $(BUILD_DIR) 22 | 23 | run: build-fs 24 | $(GOBUILD) -o $(BUILD_DIR)/hkcam -ldflags "-X main.Version=dev -X main.Date=$$(date +%FT%TZ%z)" cmd/hkcam/main.go cmd/hkcam/fs.go 25 | $(BUILD_DIR)/hkcam --verbose --data_dir=cmd/hkcam/db 26 | 27 | package: build 28 | tar -cvzf $(PACKAGE_RPI).tar.gz -C $(BUILD_DIR) $(PACKAGE_RPI) 29 | 30 | build: build-fs 31 | GOOS=linux GOARCH=arm GOARM=6 $(GOBUILD) -o $(BUILD_DIR)/$(PACKAGE_RPI)/usr/bin/hkcam -ldflags "-X main.Version=dev -X main.Date=$$(date +%FT%TZ%z)" cmd/hkcam/main.go cmd/hkcam/fs.go 32 | -------------------------------------------------------------------------------- /RASPBIAN.md: -------------------------------------------------------------------------------- 1 | # Create a small(er) image from an sd-card. 2 | 3 | **List disks** 4 | ```sh 5 | # macOS 6 | diskutil list 7 | # Linux 8 | fdisk -l 9 | ``` 10 | 11 | **Unmount disk on macOS (eg disk3)** 12 | ```sh 13 | diskutil unmountDisk /dev/rdisk3 14 | ``` 15 | 16 | **Erase disk on macOS** 17 | ```sh 18 | sudo diskutil eraseDisk FAT32 MBRFormat /dev/disk3 19 | # or 20 | sudo diskutil zeroDisk /dev/disk3 21 | ``` 22 | 23 | **Install Raspbian on macOS** 24 | ```sh 25 | sudo dd if=~/Downloads/2018-11-13-raspbian-stretch-lite.img of=/dev/rdisk3 bs=1m 26 | ``` 27 | 28 | **Create image from sd card on Linux** 29 | ```sh 30 | sudo dd if=/dev/sda | gzip > image.img.gz bs=1M 31 | ``` 32 | 33 | **Copy disk image to sd-card on Linux** 34 | ```sh 35 | gzip -dc image.img.gz | sudo dd of=/dev/sda bs=1M 36 | ``` 37 | 38 | # Create a custom Raspbian Stretch image 39 | 40 | 1. configure Raspberry Pi 41 | 42 | - install [Raspbian](https://www.raspberrypi.org/downloads/raspbian/) 43 | - enable ssh 44 | ```sh 45 | touch /Volumes/boot/ssh 46 | ``` 47 | - enable Wifi 48 | ```sh 49 | # Define ssid and password 50 | export WIFI_SSID=wifi; export WIFI_PWD=mypassword; 51 | 52 | # Write network credentials into /Volumes/boot/wpa_supplicant.conf 53 | echo "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev 54 | update_config=1 55 | 56 | network={ 57 | ssid=\"$WIFI_SSID\" 58 | psk=\"$WIFI_PWD\" 59 | }" > /Volumes/boot/wpa_supplicant.conf 60 | ``` 61 | 62 | 2. copy ssh key to the Raspberry Pi 63 | ```sh 64 | ssh-copy-id pi@raspberrypi.local 65 | ``` 66 | 67 | 3. run the `rpi` playbook 68 | ```sh 69 | #! /bin/sh 70 | cd $GOPATH/src/github.com/brutella/hkcam/ansible && ansible-playbook rpi.yml -i hosts 71 | ``` 72 | 4. check if camera works 73 | 5. erase personal data from Raspberry Pi 74 | 75 | - ssh on Raspberry Pi 76 | ```sh 77 | #! /bin/sh 78 | ssh pi@raspberrypi.local 79 | ``` 80 | - cleanup data 81 | ```sh 82 | #! /bin/sh 83 | sudo su 84 | # stop services 85 | sv stop hkcam 86 | 87 | # delete hkcam data 88 | rm -rf /var/lib/hkcam/data/* 89 | rm -rf /var/log/hkcam/* 90 | 91 | # delete wifi password 92 | sh -c "echo 'ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev 93 | update_config=1 94 | 95 | network={ 96 | }' > /etc/wpa_supplicant/wpa_supplicant.conf" 97 | 98 | # delete content from ansible and ssh-copy-id 99 | rm -rf /home/pi/.ansible/ 100 | rm -rf ~/.ssh 101 | 102 | # shutdown 103 | shutdown now 104 | ``` 105 | 6. put sd card into another linux machine 106 | 7. resize sd card 107 | ```sh 108 | # shrink root file system to a minimum (-M) 109 | e2fsck -f /dev/sda2 110 | resize2fs -M /dev/sda2 111 | #> The filesystem on /dev/sda2 is now 504923 (4k) blocks long. 112 | # Remember the block size (4k) and count (504923) 113 | # Now shrink the partition to 504923 * 4k = 2019692k 114 | # https://askubuntu.com/questions/780284/shrinking-ext4-partition-on-command-line 115 | fdisk /dev/sda 116 | # 1. Delete partition 2 117 | d 118 | # 2. Create new primary partition 2 119 | # - same START sector 120 | # - new END sector +2019692K (note '+' and uppercase 'K') 121 | n 122 | # 3. Check partition table 123 | p 124 | # 3. Commit changes 125 | w 126 | # enlarge file system 127 | resize2fs -p /dev/sda2 128 | ``` 129 | 8. create disk image until last partition end – https://serverfault.com/a/853753 130 | ```sh 131 | # Determine Units and End 132 | fdisk -l -u=cylinders /dev/sda 133 | # Example 134 | # Units: 2048 * 512 = 1048576 bytes 135 | # End: 2066 136 | dd if=/dev/sda bs=1048576 count=2066 conv=sparse | gzip > image.img.gz 137 | # or 138 | dd if=/dev/sda of=~/image.img bs=1048576 count=2066 conv=sparse 139 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hkcam 2 | 3 | `hkcam` is an open-source implementation of an HomeKit IP camera. 4 | It uses `ffmpeg` to access the camera stream and publishes the stream to HomeKit using [hap](https://github.com/brutella/hap). 5 | The camera stream can be viewed in a HomeKit app. For example my [Home+](https://hochgatterer.me/home) app works perfectly with `hkcam`. 6 | 7 | Camera Stream 8 | 9 | ## Features 10 | 11 | - Live streaming via HomeKit 12 | - Works with any HomeKit app (ex. [Home+](https://hochgatterer.me/home)) 13 | - [Multistream Support](#multistream) 14 | - [Persistent Snapshots](#persistent-snapshots) 15 | - [Built-in Web Interface](#web-interface) 16 | - Runs on multiple platforms (Raspberry Pi OS, macOS) 17 | 18 | ## Get Started 19 | 20 | *hkcam uses Go modules and therefore requires Go 1.11 or higher.* 21 | 22 | ### Mac 23 | 24 | The fastest way to get started is to 25 | 26 | 1. download the project on a Mac with a built-in iSight camera 27 | ```sh 28 | git clone https://github.com/brutella/hkcam && cd hkcam 29 | ``` 30 | 2. build and run `cmd/hkcam/main.go` by running `task hkcam` in Terminal 31 | 3. open any HomeKit app and add the camera to HomeKit (pin for initial setup is `001 02 003`) 32 | 3. view the web ui by entering the url http://localhost:8080 33 | 34 | These steps require *git*, *go*, *task* and *ffmpeg* to be installed. On macOS you can install them via Homebrew. 35 | 36 | ```sh 37 | brew install git 38 | brew install go 39 | brew install go-task/tap/go-task 40 | brew install ffmpeg 41 | ``` 42 | 43 | ### Raspberry Pi 44 | 45 | You can use a camera module or USB camera with a Raspberry Pi to create your own surveillance camera. 46 | 47 | ELP 1080p 48 | 49 | For example the [ELP 1080P USB camera dome](https://de.aliexpress.com/item/4000562253329.html) is great for outdoor use. It is IP66 waterproof and has built-in IR LEDs for night vision. This camera gets you good quality and great performance when running `hkcam` on the latest Raspberry Pi OS. 50 | 51 | A cheaper alternative is a [camera module](https://www.raspberrypi.com/products/camera-module-v2/) attached via ribbon cable. You'll have to enable **[Legacy Camera Support](https://www.raspberrypi.com/documentation/accessories/camera.html#libcamera-and-the-legacy-raspicam-camera-stack)** when using a camera module on Raspberry Pi OS. That's why this option is not ideal in my opinion. 52 | 53 | --- 54 | 55 | **How to Install on a Raspberry Pi?** 56 | 57 | Follow these steps to install `hkcam` and all the required libraries on a Raspberry Pi OS Lite (32-bit). 58 | 59 | 1. Download and run the Raspberry Pi Imager from https://www.raspberrypi.com/software/ 60 | Raspberry Pi Imager 61 | 62 | - Choose OS → Raspberry Pi OS (other) → Raspberry Pi OS Lite (32-bit) 63 | Raspberry Pi Imager 64 | 65 | - Insert a sd card into your computer and choose it as the storage 66 | Raspberry Pi Imager 67 | 68 | - Click on the settings icon and **enable SSH**, **Set username and password** and **configure wifi** 69 | Raspberry Pi Imager 70 | 71 | - Write the operating system on the sd card by clicking on **Write** 72 | Raspberry Pi Imager 73 | 74 | 2. Insert the sd card in your Raspberry Pi 75 | 3. Connect your camera (in my case the ELP 1080P) and power supply 76 | 4. Connect to your Raspberry Pi via SSH (the first boot may take a while, so be patient) 77 | `ssh pi@raspberrypi.local` (enter your previously configured password) 78 | 79 | 5. Install ffmpeg 80 | `apt-get install ffmpeg` 81 | 82 | 6. Install v4l2loopback 83 | `apt-get install v4l2loopback-dkms` 84 | 85 | - Enable v4l2loopback module at boot by creating a file `/etc/modules-load.d/v4l2loopback.conf` with the content 86 | 87 | ``` 88 | v4l2loopback 89 | ``` 90 | 91 | - Specify which loopback file should be created by the module (in our case /dev/video99) by creating the file `/etc/modprobe.d/v4l2loopback.conf` with the content 92 | ``` 93 | options v4l2loopback video_nr=99 94 | ``` 95 | 96 | - Restart the Raspberry Pi and verify that the file `/dev/video99` exists 97 | 98 | 7. Install `hkcam` 99 | 100 | - Download the latest release from https://github.com/brutella/hkcam/releases 101 | ``` 102 | wget https://github.com/brutella/hkcam/releases/download/v0.1.0/hkcam-v0.1.0_linux_arm.tar.gz 103 | ``` 104 | 105 | - Extract the archive with `tar -xzf hkcam-v0.1.0_linux_arm.tar.gz` 106 | - Run `hkcam` by executing the following command 107 | ``` 108 | ./hkcam -data_dir=/var/lib/hkcam/data -multi_stream=true -port=8080 -verbose 109 | ``` 110 | 111 | 8. Add the camera to HomeKit 112 | 113 | - Launch the Apple Home-app and tap *+* → Add Accessory 114 | 115 | - Tap *More Options...* 116 | 117 | More options 118 | 119 | - Select *Camera* and confirm that the accessory is uncertified 120 | 121 | Select Accessory 122 | 123 | - Enter the pin `001-02-003` and Continue 124 | 125 | Select Accessory 126 | 127 | If everything works as expected, you have to configure `hkcam` as a daemon – so that hkcam is automatically run after boot. 128 | This can be done in different way – [systemd](https://www.raspberrypi.com/documentation/computers/using_linux.html#the-systemd-daemon) is recommended, 129 | 130 | 131 | **How to install with Ansible?** 132 | 133 | I've made an [Ansible](http://docs.ansible.com/ansible/index.html) playbook which configures your Raspberry Pi and installs hkcam. 134 | The following steps require *ansible* to be installed. On macOS you can install it via Homebrew. 135 | ```sh 136 | brew install ansible 137 | ``` 138 | 139 | --- 140 | 141 | First install Raspberry Pi OS, as described above. 142 | Then create ssh key and copy them to the Raspberry Pi. 143 | 144 | ```sh 145 | ssh-keygen 146 | ssh-copy-id pi@raspberrypi.local 147 | ``` 148 | 149 | After that you can execute the playbook with the following command. 150 | 151 | ```sh 152 | cd ansible && ansible-playbook rpi.yml -i hosts 153 | ``` 154 | 155 | Once the command finishes, your camera can be added to HomeKit. 156 | 157 | ## Multistream 158 | 159 | Normally in HomeKit a camera stream can only be viewed by one device at a time. 160 | If a second device wants to view the stream, the Apple Home app shows 161 | 162 | > **Camera Not Available** 163 | > Wait until someone else in this home stops viewing this camera and try again. 164 | 165 | `hkcam` allows multiple devices to view the same stream by setting the option `-multi_stream=true`. That's neat. 166 | 167 | ## Persistent Snapshots 168 | 169 | In addition to video streaming, `hkcam` supports [Persistent Snapshots](/SNAPSHOTS.md). 170 | *Persistent Snapshots* is a way to take snapshots of the camera and store them on disk. 171 | You can then access them via HomeKit. 172 | 173 | *Persistent Snapshots* are currently supported by [Home+](https://hochgatterer.me/home), 174 | as you can see from the following screenshots. 175 | 176 | Live streaming 177 | Snapshots 178 | 179 | Taking snapshots in automations is also supported. 180 | 181 | Automation 182 | 183 | ## Web Interface 184 | 185 | When running `hkcam` at a specific port (by specifying `-port=...`), you can access the web interface at the url http://{ip-address}:{port} with a browser. 186 | The web interface shows the recent snapshot and lets you install updates. 187 | 188 | Web Interface 189 | 190 | ## Raspberry Pi Zero W 191 | 192 | I do get kernel panics when running hkcam with an ELP 1080P USB camera. 193 | Updating `/boot/config.txt` with the following changes resolve those kernel panics. 194 | 195 | ``` 196 | arm_freq=800 197 | arm_freq_max=900 198 | arm_freq_min=700 199 | ``` 200 | 201 | ## Raspberry Pi Zero W Enclosure 202 | 203 | Desk mount 204 | Wall mount 205 | 206 | I've also designed an enclosure for the Raspberry Pi Zero W and standard camera module. 207 | You can use a stand to put the camera on a desk, or combine it with brackets of the [Articulating Raspberry Pi Camera Mount](https://www.prusaprinters.org/prints/3407-articulating-raspberry-pi-camera-mount-for-prusa-m) to mount it on a wall. 208 | The 3D-printed parts are available as STL files [here](https://github.com/brutella/hkcam/tree/master/enclosure). 209 | 210 | This enclosure is not waterproof and should not be used outside. Instead, you should use an [ELP 1080P camera](https://de.aliexpress.com/item/4000562253329.html) and connect it via USB to a Raspberry Pi. 211 | 212 | 358 | 359 | # Contact 360 | 361 | Matthias Hochgatterer 362 | 363 | Website: [http://hochgatterer.me](http://hochgatterer.me) 364 | 365 | Github: [https://github.com/brutella](https://github.com/brutella) 366 | 367 | Twitter: [https://twitter.com/brutella](https://twitter.com/brutella) 368 | 369 | 370 | # License 371 | 372 | `hkcam` is available under the Apache License 2.0 license. See the LICENSE file for more info. 373 | -------------------------------------------------------------------------------- /SNAPSHOTS.md: -------------------------------------------------------------------------------- 1 | # Persistent Snapshots 2 | 3 | *Persistent Snapshots* is a way to take snapshots of the camera and store them on disk. 4 | You can then access them via HomeKit. 5 | *Persistent Snapshots* is not defined in the HAP but instead implemented by `hkcam` with custom characteristics. 6 | 7 | *Persistent Snapshots* are supported by [Home+](https://hochgatterer.me/home+). 8 | 9 | ## Why? 10 | 11 | Taking snapshots is an essential feature of any security camera. 12 | For example, you want to take a snapshot once motion is detected in room. 13 | But there are currently no IP cameras which support that via HomeKit. 14 | 15 | `hkcam` implements *Persistent Snapshots* with custom HomeKit characteristics. 16 | This means you can use this feature in HomeKit scenes and automations. 17 | 18 | ## Custom Characteristics 19 | 20 | The following characteristics are used to handle snapshots. 21 | 22 | - [TakeSnapshot](/take_snapshot.go) takes a snapshot. 23 | - [Assets](/assets.go) returns an index of all snapshots as JSON. 24 | - [GetAsset](/get_asset.go) returns JPEG data representing a snapshot. 25 | - [DeleteAssets](/delete_assets.go) deletes snapshots. 26 | 27 | To take a snapshot, you should write `true` to the [TakeSnapshot](/take_snapshot.go) characteristic. 28 | 29 | --- 30 | 31 | After that the [Assets](/assets.go) characteristic contains the snapshot in the returned JSON. 32 | The value of the [Assets](/assets.go) characteristic might look like this. 33 | 34 | ```json 35 | { 36 | "assets":[ 37 | { 38 | "id": "1.jpg", 39 | "date": "2019-04-01T10:00:00+00:00" 40 | } 41 | ] 42 | } 43 | ``` 44 | 45 | --- 46 | 47 | To get the data of the snapshot with id `1.jpg`, you should send the following JSON to the [GetAsset](/get_asset.go) characteristic. 48 | 49 | ```json 50 | { 51 | "id": "1.jpg", 52 | "width": 320, 53 | "height": 240 54 | } 55 | ``` 56 | 57 | If you omit `width` or `height` (or set it to `0`), the image keeps the aspect ratio while resizing. 58 | 59 | After a successful write, the [GetAsset](/get_asset.go) characteristic value contains the JPEG data. 60 | 61 | --- 62 | 63 | If you want to delete the snapshot, you send the following JSON to the [DeleteAssets](/delete_assets.go) characteristic. 64 | 65 | ```json 66 | { 67 | "ids": [ 68 | "1.jpg" 69 | ] 70 | } 71 | ``` -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | # expansions: 3 4 | 5 | vars: 6 | PWD: 7 | sh: pwd 8 | TAG: 9 | sh: git describe --tags 10 | VERSION: "{{or .BUILD_VERSION .TAG}}" 11 | DATE: 12 | sh: date +%FT%TZ%z 13 | BUILD_DIR: "{{ .PWD }}/build" 14 | 15 | tasks: 16 | clean: 17 | cmds: 18 | - go clean 19 | - rm -rf "{{ .BUILD_DIR }}" 20 | test: 21 | cmds: 22 | - go test ./... -race -count=1 23 | lint: 24 | cmds: 25 | - golangci-lint run 26 | build-fs: 27 | cmds: 28 | - go run github.com/mjibson/esc -o cmd/hkcam/fs.go -ignore ".*\.go" html static 29 | hkcam: 30 | cmds: 31 | - task: build-fs 32 | - "go build -o {{ .BUILD_DIR }}/hkcam -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go" 33 | - "{{ .BUILD_DIR }}/hkcam --verbose --port={{ .PORT }} --data_dir=cmd/hkcam/db" 34 | vars: 35 | LDFLAGS: "\"-X main.Version={{ .VERSION }} -X main.Date={{ .DATE }}\"" 36 | PORT: '{{ default "8080" .PORT }}' 37 | sources: 38 | - static/**/* 39 | - cmd/hkcam/main.go 40 | - api/*.go 41 | - app/*.go 42 | - html/**/* 43 | - Taskfile.yml 44 | pack: 45 | cmds: 46 | - task: build-fs 47 | # Raspberry Pi 48 | - "GOOS=linux GOARCH=arm GOARM=6 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_RPI }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go" 49 | # Linux 50 | - "GOOS=linux GOARCH=amd64 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_LINUX_64 }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go" 51 | # Linux 52 | - "GOOS=linux GOARCH=386 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_LINUX_32 }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go" 53 | # Intel Mac 54 | - "GOOS=darwin GOARCH=amd64 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_INTEL_MAC }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go" 55 | # M1 Mac 56 | - "GOOS=darwin GOARCH=arm64 go build -o {{ .BUILD_DIR }}/{{ .PACKAGE_M1_MAC }}/{{ .BINARY }} -ldflags {{ .LDFLAGS }} cmd/hkcam/main.go cmd/hkcam/fs.go" 57 | # pack 58 | - "tar -cvzf {{ .PACKAGE_RPI }}.tar.gz -C {{ .BUILD_DIR }}/{{ .PACKAGE_RPI }} {{ .BINARY }}" 59 | - "tar -cvzf {{ .PACKAGE_LINUX_64 }}.tar.gz -C {{ .BUILD_DIR }}/{{ .PACKAGE_LINUX_64 }} {{ .BINARY }}" 60 | - "tar -cvzf {{ .PACKAGE_LINUX_32 }}.tar.gz -C {{ .BUILD_DIR }}/{{ .PACKAGE_LINUX_32 }} {{ .BINARY }}" 61 | - "tar -cvzf {{ .PACKAGE_INTEL_MAC }}.tar.gz -C {{ .BUILD_DIR }}/{{ .PACKAGE_INTEL_MAC }} {{ .BINARY }}" 62 | - "tar -cvzf {{ .PACKAGE_M1_MAC }}.tar.gz -C {{ .BUILD_DIR }}/{{ .PACKAGE_M1_MAC }} {{ .BINARY }}" 63 | vars: 64 | BINARY: hkcam 65 | PACKAGE_RPI: "{{ .BINARY }}-{{ .VERSION }}_linux_arm" 66 | PACKAGE_LINUX_64: "{{ .BINARY }}-{{ .VERSION }}_linux_amd64" 67 | PACKAGE_LINUX_32: "{{ .BINARY }}-{{ .VERSION }}_linux_386" 68 | PACKAGE_INTEL_MAC: "{{ .BINARY }}-{{ .VERSION }}_darwin_amd64" 69 | PACKAGE_M1_MAC: "{{ .BINARY }}-{{ .VERSION }}_darwin_arm64" 70 | LDFLAGS: "\"-X main.Version={{ .VERSION }} -X main.Date={{ .DATE }}\"" -------------------------------------------------------------------------------- /_img/assembly-camera.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/assembly-camera.jpg -------------------------------------------------------------------------------- /_img/assembly-lid-beam1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/assembly-lid-beam1.jpg -------------------------------------------------------------------------------- /_img/assembly-lid-beam2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/assembly-lid-beam2.jpg -------------------------------------------------------------------------------- /_img/assembly-lid-snap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/assembly-lid-snap.jpg -------------------------------------------------------------------------------- /_img/assembly-rpi.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/assembly-rpi.jpg -------------------------------------------------------------------------------- /_img/automation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/automation.jpg -------------------------------------------------------------------------------- /_img/elp-1080p.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/elp-1080p.jpg -------------------------------------------------------------------------------- /_img/enclosure-desk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/enclosure-desk.jpg -------------------------------------------------------------------------------- /_img/enclosure-wall.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/enclosure-wall.jpg -------------------------------------------------------------------------------- /_img/etcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/etcher.png -------------------------------------------------------------------------------- /_img/home-app-camera.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/home-app-camera.jpeg -------------------------------------------------------------------------------- /_img/home-app-more-options.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/home-app-more-options.jpeg -------------------------------------------------------------------------------- /_img/home-app-pin.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/home-app-pin.jpeg -------------------------------------------------------------------------------- /_img/home-app-select-camera.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/home-app-select-camera.jpeg -------------------------------------------------------------------------------- /_img/homeplus-automation.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/homeplus-automation.jpeg -------------------------------------------------------------------------------- /_img/homeplus-snapshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/homeplus-snapshots.png -------------------------------------------------------------------------------- /_img/homeplus-stream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/homeplus-stream.png -------------------------------------------------------------------------------- /_img/live-stream.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/live-stream.jpg -------------------------------------------------------------------------------- /_img/loose-camera-cables.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/loose-camera-cables.jpg -------------------------------------------------------------------------------- /_img/rpi-imager-os.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/rpi-imager-os.png -------------------------------------------------------------------------------- /_img/rpi-imager-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/rpi-imager-settings.png -------------------------------------------------------------------------------- /_img/rpi-imager-storage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/rpi-imager-storage.png -------------------------------------------------------------------------------- /_img/rpi-imager-write.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/rpi-imager-write.png -------------------------------------------------------------------------------- /_img/rpi-imager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/rpi-imager.png -------------------------------------------------------------------------------- /_img/services.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/services.jpg -------------------------------------------------------------------------------- /_img/snapshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/snapshot.jpg -------------------------------------------------------------------------------- /_img/snapshots.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/snapshots.jpg -------------------------------------------------------------------------------- /_img/web-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/_img/web-interface.png -------------------------------------------------------------------------------- /ansible/group_vars/all: -------------------------------------------------------------------------------- 1 | --- 2 | ansible_connection: ssh 3 | ansible_ssh_user: pi -------------------------------------------------------------------------------- /ansible/hosts: -------------------------------------------------------------------------------- 1 | [rpi] 2 | raspberrypi.local -------------------------------------------------------------------------------- /ansible/roles/hkcam/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | hkcam_version: '0.2.0' 3 | hkcam_download_file_name: hkcam-{{ hkcam_version }}_linux_arm.tar.gz 4 | hkcam_download_url: https://github.com/brutella/hkcam/releases/download/{{ hkcam_version }}/{{ hkcam_download_file_name }} 5 | hkcam_download_dir: /tmp 6 | hkcam_download_dest: "{{ hkcam_download_dir }}/{{ hkcam_download_file_name }}" 7 | hkcam_data_dir: /var/lib/hkcam/data -------------------------------------------------------------------------------- /ansible/roles/hkcam/tasks/configure.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Update packages 4 | apt: 5 | update_cache: yes 6 | upgrade: yes 7 | 8 | # - name: Reboot 9 | # changed_when: false 10 | # reboot: 11 | # reboot_timeout: 200 12 | 13 | - name: Install packages 14 | apt: 15 | name: "{{ packages }}" 16 | state: present 17 | vars: 18 | packages: 19 | - ffmpeg 20 | - v4l2loopback-dkms 21 | 22 | - name: Enable v4l2loopback module 23 | copy: 24 | dest: "/etc/modules-load.d/v4l2loopback.conf" 25 | content: "v4l2loopback" 26 | 27 | - name: Set loopback file /dev/video99 28 | copy: 29 | dest: "/etc/modprobe.d/v4l2loopback.conf" 30 | content: "options v4l2loopback video_nr=99" 31 | -------------------------------------------------------------------------------- /ansible/roles/hkcam/tasks/install.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Prepare {{ hkcam_download_dir }} directory 3 | file: 4 | path: "{{ hkcam_download_dir }}" 5 | state: directory 6 | 7 | - name: Download {{ hkcam_download_url }} to {{ hkcam_download_dest }} 8 | # copy: 9 | # src: /Users/mah/Source/Code/Go/src/github.com/brutella/hkcam/{{ hkcam_download_file_name }} 10 | # dest: "{{ hkcam_download_dir }}" 11 | # owner: pi 12 | # group: pi 13 | # mode: 0644 14 | get_url: 15 | url: "{{ hkcam_download_url }}" 16 | dest: "{{ hkcam_download_dest }}" 17 | register: pkg_download 18 | 19 | - name: Extract {{ hkcam_download_dest }} to {{ hkcam_download_dir }} 20 | unarchive: 21 | src: "{{ pkg_download.dest }}" 22 | dest: "{{ hkcam_download_dir }}" 23 | remote_src: true 24 | list_files: true 25 | register: unarchived 26 | 27 | - name: Define extracted folder 28 | set_fact: unarchived_dir="{{ hkcam_download_dir }}/{{ unarchived.files[0] | dirname }}" 29 | - name: Copy {{ unarchived_dir }}/hkcam to /usr/bin 30 | shell: cp -rp {{ unarchived_dir }}/hkcam /usr/bin 31 | 32 | - name: Ensure that {{ hkcam_data_dir }} exists 33 | file: path={{ hkcam_data_dir }} mode=0755 state=directory 34 | 35 | # FIXME Use separate user to execute hkcam. It doesn't work because the user doesn't have permissions to access */dev/video0* even though being in the *video* group. 36 | # - name: Create hkcam user 37 | # user: 38 | # name: hkcam 39 | # state: present 40 | # system: true 41 | # 42 | # - name: Add user to video group 43 | # user: 44 | # name: hkcam 45 | # groups: video 46 | # append: yes 47 | # 48 | # - name: Change permission of data directory 49 | # file: 50 | # dest: "{{ hkcam_data_dir }}" 51 | # owner: hkcam 52 | # group: hkcam 53 | # mode: 0755 54 | # recurse: true 55 | -------------------------------------------------------------------------------- /ansible/roles/hkcam/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - include: install.yml 3 | tags: [install] 4 | 5 | - include: configure.yml 6 | tags: [configure] 7 | 8 | - import_role: 9 | name: runit 10 | vars: 11 | service_name: hkcam 12 | run_script: | 13 | #!/bin/sh -e 14 | exec 2>&1 15 | 16 | exec hkcam --data_dir={{ hkcam_data_dir }} --verbose=true 17 | log_dir: /var/log/hkcam 18 | tags: [runit] -------------------------------------------------------------------------------- /ansible/roles/runit/README: -------------------------------------------------------------------------------- 1 | The runit role lets you run services using runit. 2 | 3 | Options 4 | - enabled (boolean, optional) – If `true`, the service is registered in runit. If `false`, the service is unregistered in runit. 5 | - state (string: `started`, `stopped`, optional) – If `stopped` the service should be stopped. If `started` the service should be started. 6 | - envs (dict, optional) – A list of key-value pairs for environment variables. 7 | - service_name (string, required) – The name of the service 8 | - run_script (shell script) – The script to run the service 9 | For example 10 | 11 | #!/bin/sh -e 12 | # redirects all stderr output to stdout 13 | exec 2>&1 14 | exec telegraf --config /etc/telegraf/telegraf.conf 15 | 16 | - finish_script (shell script) – The script which is executed when the run script finishes. 17 | - log_dir (string) – Path to the log directory, e.g. `/var/log/{service_name}` -------------------------------------------------------------------------------- /ansible/roles/runit/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | runit_services_dir: /etc/service 3 | runit_runsvdir_dir: /etc/sv 4 | runit_startup_file: /sbin/runsvdir-start -------------------------------------------------------------------------------- /ansible/roles/runit/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | allow_duplicates: true -------------------------------------------------------------------------------- /ansible/roles/runit/tasks/add.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - assert: 3 | that: service_name is defined 4 | fail_msg: Variable '{{ service_name }}' ist not defined 5 | 6 | - assert: 7 | that: run_script is defined 8 | fail_msg: Variable '{{ run_script }}' ist not defined 9 | 10 | - assert: 11 | that: log_dir is defined 12 | fail_msg: Variable '{{ log_dir }}' ist not defined 13 | 14 | - name: Define runit service directory 15 | set_fact: 16 | service_run_dir: "{{ runit_services_dir }}/{{ service_name }}" 17 | - name: Define runit service log directory 18 | set_fact: 19 | service_log_run_dir: "{{ service_run_dir }}/log" 20 | - name: Define runit service env directory 21 | set_fact: 22 | service_env_dir: "{{ service_run_dir }}/env" 23 | 24 | - name: Ensure that {{ service_env_dir }} exists 25 | file: 26 | path: "{{ service_env_dir }}" 27 | mode: 0755 28 | state: directory 29 | - name: Ensure that {{ log_dir }} exists 30 | file: 31 | path: "{{ log_dir }}" 32 | mode: 0755 33 | state: directory 34 | - name: Ensure that {{ service_run_dir }} exists 35 | file: 36 | path: "{{ service_run_dir }}" 37 | mode: 0755 38 | state: directory 39 | - name: Ensure that {{ service_log_run_dir }} exists 40 | file: 41 | path: "{{ service_log_run_dir }}" 42 | mode: 0755 43 | state: directory 44 | 45 | - name: Create service run file {{ service_run_dir }}/run 46 | template: 47 | src: run.j2 48 | dest: "{{ service_run_dir }}/run" 49 | mode: 0755 50 | 51 | - name: Create log run file {{ service_log_run_dir }}/run 52 | template: 53 | src: log-run.j2 54 | dest: "{{ service_log_run_dir }}/run" 55 | mode: 0755 56 | 57 | # Add/remove finish file 58 | - name: Create finish file {{ service_run_dir }}/finish 59 | template: 60 | src: finish.j2 61 | dest: "{{ service_run_dir }}/finish" 62 | mode: 0755 63 | when: finish_script is defined 64 | - name: Remove finish file {{ service_run_dir }}/finish 65 | file: 66 | path: "{{ service_run_dir }}/finish" 67 | state: absent 68 | when: finish_script is not defined -------------------------------------------------------------------------------- /ansible/roles/runit/tasks/enabled.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Define service directory 3 | set_fact: 4 | service_run_dir: "{{ runit_services_dir }}/{{ service_name }}" 5 | 6 | - name: Define supervised directory 7 | set_fact: 8 | service_runsvdir_dir: "{{ runit_runsvdir_dir }}/{{ service_name }}" 9 | 10 | - name: Check for service directory 11 | stat: 12 | path: "{{ service_run_dir }}" 13 | register: p 14 | 15 | - assert: 16 | that: p.stat.exists 17 | fail_msg: "{{ service_run_dir }} does not exist" 18 | 19 | - assert: 20 | that: p.stat.isdir 21 | fail_msg: "{{ service_run_dir }} is not a directory" 22 | 23 | - name: Check for supervised directory 24 | stat: 25 | path: "{{ service_runsvdir_dir }}" 26 | register: p 27 | 28 | - name: Enable {{ service_name }} service 29 | file: 30 | src: "{{ runit_services_dir }}/{{ service_name }}" 31 | dest: "{{ runit_runsvdir_dir }}/{{ service_name }}" 32 | state: link 33 | when: enabled and p.stat.exists == false 34 | 35 | - name: Disable {{ service_name }} service 36 | file: 37 | src: "{{ runit_services_dir }}/{{ service_name }}" 38 | dest: "{{ runit_runsvdir_dir }}/{{ service_name }}" 39 | state: absent 40 | when: enabled == False and p.stat.exists -------------------------------------------------------------------------------- /ansible/roles/runit/tasks/envs.yml: -------------------------------------------------------------------------------- 1 | # Sets environment variables by creating a directory called *env* in the service root directory. 2 | # 3 | # Example 4 | # 5 | # envs: 6 | # TOKEN: 1234ABCD 7 | # 8 | # creates the file `env/TOKEN` with the content `1234ABCD`. 9 | --- 10 | - name: Define runit service directory 11 | set_fact: 12 | service_run_dir: "{{ runit_services_dir }}/{{ service_name }}" 13 | 14 | - name: Define runit service env directory 15 | set_fact: 16 | service_env_dir: "{{ service_run_dir }}/env" 17 | 18 | - name: Set env variables 19 | copy: 20 | content: "{{ item.value }}" 21 | dest: "{{ service_env_dir }}/{{ item.key }}" 22 | with_dict: "{{ envs }}" 23 | 24 | - name: Define all env files 25 | find: 26 | paths: "{{ service_env_dir }}" 27 | register: found 28 | 29 | - name: Delete unused env files 30 | file: 31 | path: "{{ item.path }}" 32 | state: absent 33 | when: "(item.path|basename) is not in envs" 34 | with_items: "{{ found.files }}" -------------------------------------------------------------------------------- /ansible/roles/runit/tasks/install.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install runit 3 | apt: 4 | name: runit 5 | state: present 6 | 7 | - name: Ensure that {{ runit_services_dir }} exists 8 | file: path={{ runit_services_dir }} mode=0755 state=directory 9 | 10 | - name: Ensure that {{ runit_runsvdir_dir }} exists 11 | file: path={{ runit_runsvdir_dir }} mode=0755 state=directory 12 | 13 | - name: Check if {{ runit_startup_file }} exists 14 | stat: path={{ runit_startup_file }} get_md5=no get_checksum=no 15 | register: file 16 | 17 | - name: Create startup script at {{ runit_startup_file }} 18 | when: file.stat.exists == false 19 | template: 20 | src: runsvdir-start.j2 21 | dest: "{{ runit_startup_file }}" 22 | mode: 0755 23 | 24 | - name: Ensure that runit is launched from /etc/rc.local 25 | shell: cat /etc/rc.local | grep {{ runit_startup_file }} 26 | args: 27 | warn: false # prevent warning message 28 | register: check 29 | failed_when: false # never fails 30 | changed_when: false # never changes 31 | 32 | - name: Add line to launch runit on startup 33 | shell: sed -i -e '$i {{ runit_startup_file }} &\n' /etc/rc.local 34 | args: 35 | warn: false 36 | when: check.rc != 0 -------------------------------------------------------------------------------- /ansible/roles/runit/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - include: install.yml 3 | 4 | - include: add.yml 5 | when: service_name is defined 6 | 7 | - include: enabled.yml 8 | when: enabled is defined and service_name is defined 9 | 10 | - include: envs.yml 11 | when: envs is defined and service_name is defined 12 | 13 | - include: state.yml 14 | when: state is defined and service_name is defined -------------------------------------------------------------------------------- /ansible/roles/runit/tasks/state.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Starting {{ service_name }} service 3 | shell: sv up {{ service_name }} 4 | when: state == "started" 5 | 6 | - name: Stopping {{ service_name }} service 7 | shell: sv down {{ service_name }} 8 | when: state == "stopped" 9 | 10 | - name: Restarting {{ service_name }} service 11 | shell: sv restart {{ service_name }} 12 | when: state == "restarted" -------------------------------------------------------------------------------- /ansible/roles/runit/templates/finish.j2: -------------------------------------------------------------------------------- 1 | {{ finish_script }} -------------------------------------------------------------------------------- /ansible/roles/runit/templates/log-run.j2: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec svlogd -tt {{ log_dir }} -------------------------------------------------------------------------------- /ansible/roles/runit/templates/run.j2: -------------------------------------------------------------------------------- 1 | {{ run_script }} -------------------------------------------------------------------------------- /ansible/roles/runit/templates/runsvdir-start.j2: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export PATH=/usr/local/bin:/usr/local/sbin:/bin:/sbin:/usr/bin:/usr/sbin:/usr/X11R6/bin 4 | 5 | exec runsvdir -P {{ runit_runsvdir_dir }} 'log: ...........................................................................................................................................................................................................................................................................................................................................................................................................' -------------------------------------------------------------------------------- /ansible/rpi.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: rpi 3 | user: pi 4 | become: true 5 | roles: 6 | - role: hkcam 7 | enabled: true 8 | tasks: 9 | - name: Reboot 10 | changed_when: false 11 | reboot: 12 | reboot_timeout: 200 13 | - name: Ping rpi 14 | changed_when: false 15 | shell: ping -c 1 -i 5 {{ ansible_host }} 16 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/brutella/hkcam/app" 5 | "github.com/go-chi/chi" 6 | 7 | "net/http" 8 | ) 9 | 10 | const ( 11 | ErrorInvalidPayload = 1 12 | ErrorInvalidRequest = 2 13 | ErrorUnknown = 3 14 | ) 15 | 16 | type Api struct { 17 | App *app.App 18 | } 19 | 20 | func (a *Api) Router() http.Handler { 21 | r := chi.NewRouter() 22 | r.Get("/system/heartbeat", a.SystemHeartbeat) 23 | r.Get("/system/info", a.SystemInfo) 24 | r.Post("/system/restart", a.SystemRestart) 25 | r.Get("/snapshots/recent", a.RecentSnapshot) 26 | r.Get("/snapshots/new", a.NewSnapshot) 27 | 28 | return r 29 | } 30 | 31 | // RestartApp restarts the app. 32 | func (a *Api) RestartApp() { 33 | a.App.Restart() 34 | } 35 | -------------------------------------------------------------------------------- /api/apiutil/decode.go: -------------------------------------------------------------------------------- 1 | package apiutil 2 | 3 | import ( 4 | "github.com/gorilla/schema" 5 | 6 | "net/http" 7 | "strconv" 8 | ) 9 | 10 | // ParseInt64 converts a string to an 8-byte integer 11 | func ParseInt64(s string) (int64, error) { 12 | return strconv.ParseInt(s, 10, 64) 13 | } 14 | 15 | var decoder = schema.NewDecoder() 16 | 17 | func DecodeForm(w http.ResponseWriter, r *http.Request, v interface{}) error { 18 | if err := r.ParseForm(); err != nil { 19 | return err 20 | } 21 | 22 | return decoder.Decode(v, r.Form) 23 | } 24 | 25 | func DecodeURLQuery(w http.ResponseWriter, r *http.Request, v interface{}) error { 26 | if err := r.ParseForm(); err != nil { 27 | return err 28 | } 29 | 30 | return decoder.Decode(v, r.URL.Query()) 31 | } 32 | 33 | func ToBool(s string) bool { 34 | switch s { 35 | case "on": 36 | return true 37 | case "off": 38 | return false 39 | default: 40 | v, _ := strconv.ParseBool(s) 41 | return v 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /api/apiutil/json.go: -------------------------------------------------------------------------------- 1 | package apiutil 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | func JSONEncode(v interface{}) (*bytes.Buffer, error) { 11 | buf := &bytes.Buffer{} 12 | enc := json.NewEncoder(buf) 13 | enc.SetEscapeHTML(true) 14 | err := enc.Encode(v) 15 | 16 | return buf, err 17 | } 18 | 19 | func JSONDecode(r io.Reader, v interface{}) error { 20 | return json.NewDecoder(r).Decode(v) 21 | } 22 | 23 | func WriteJSON(w http.ResponseWriter, r *http.Request, v interface{}) error { 24 | buf, err := JSONEncode(v) 25 | if err != nil { 26 | http.Error(w, err.Error(), http.StatusInternalServerError) 27 | return err 28 | } 29 | 30 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 31 | _, err = w.Write(buf.Bytes()) 32 | return err 33 | } 34 | 35 | func ReadJSON(rc io.Reader, v interface{}) error { 36 | if err := JSONDecode(rc, v); err != nil { 37 | return err 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /api/error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type ErrResponse struct { 4 | Error Error `json:"error"` 5 | } 6 | 7 | type Error struct { 8 | Message string `json:"message"` 9 | Code int `json:"code"` 10 | } 11 | 12 | func NewErrResponse(err error, code int) *ErrResponse { 13 | return &ErrResponse{ 14 | Error{ 15 | Message: err.Error(), 16 | Code: code, 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /api/json.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/brutella/hkcam/api/apiutil" 5 | "net/http" 6 | ) 7 | 8 | // WriteJSON responds to request r by encoding and sending v as json. 9 | // If v is an instance of an ErrResponse, the response status code is 400 (Bad Request). 10 | func WriteJSON(w http.ResponseWriter, r *http.Request, v interface{}) error { 11 | switch v.(type) { 12 | case *ErrResponse, ErrResponse: 13 | w.WriteHeader(http.StatusBadRequest) 14 | default: 15 | w.WriteHeader(http.StatusOK) 16 | } 17 | 18 | return apiutil.WriteJSON(w, r, v) 19 | } 20 | -------------------------------------------------------------------------------- /api/snapshot.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/brutella/hkcam/api/apiutil" 5 | 6 | "bytes" 7 | "fmt" 8 | "image/jpeg" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | // SnapshotRequest is a request restart the system. 14 | type SnapshotRequest struct { 15 | Width uint `schema:"width"` 16 | Height uint `schema:"height"` 17 | } 18 | 19 | // SnapshotResponse is a response to a SnapshotRequest. 20 | type SnapshotResponse struct { 21 | Data *SnapshotResponseData `json:"data"` 22 | } 23 | 24 | // SnapshotResponseData is the response data of a SnapshotRequest. 25 | type SnapshotResponseData struct { 26 | Date *time.Time `json:"date,omitempty"` 27 | Bytes []byte `json:"bytes"` 28 | } 29 | 30 | // RecentSnapshot responds with the recent snapshot. 31 | func (a *Api) RecentSnapshot(w http.ResponseWriter, r *http.Request) { 32 | req := SnapshotRequest{ 33 | Width: 1920, 34 | Height: 1080, 35 | } 36 | var resp interface{} 37 | 38 | if err := apiutil.DecodeURLQuery(w, r, &req); err != nil { 39 | resp = NewErrResponse(fmt.Errorf("invalid payload"), ErrorInvalidPayload) 40 | } else if req.Width == 0 || req.Height == 0 { 41 | resp = NewErrResponse(fmt.Errorf("invalid payload"), ErrorInvalidPayload) 42 | } else if snapshot := a.App.FFMPEG.RecentSnapshot(req.Width, req.Height); snapshot != nil { 43 | buf := new(bytes.Buffer) 44 | if err := jpeg.Encode(buf, snapshot.Image, nil); err != nil { 45 | resp = NewErrResponse(fmt.Errorf("encode: %v", err), ErrorUnknown) 46 | } 47 | resp = SnapshotResponse{ 48 | Data: &SnapshotResponseData{ 49 | Bytes: buf.Bytes(), 50 | Date: &snapshot.Date, 51 | }, 52 | } 53 | } else { 54 | resp = SnapshotResponse{} 55 | } 56 | 57 | if err := WriteJSON(w, r, resp); err != nil { 58 | fmt.Println("responding failed", err) 59 | } 60 | } 61 | 62 | // NewSnapshot create a new snapshot. 63 | func (a *Api) NewSnapshot(w http.ResponseWriter, r *http.Request) { 64 | req := SnapshotRequest{ 65 | Width: 1920, 66 | Height: 1080, 67 | } 68 | var resp interface{} 69 | 70 | if err := apiutil.DecodeURLQuery(w, r, &req); err != nil { 71 | resp = NewErrResponse(fmt.Errorf("invalid payload"), ErrorInvalidPayload) 72 | } else if req.Width == 0 || req.Height == 0 { 73 | resp = NewErrResponse(fmt.Errorf("invalid payload"), ErrorInvalidPayload) 74 | } else if snapshot, err := a.App.FFMPEG.Snapshot(req.Width, req.Height); err != nil { 75 | resp = NewErrResponse(fmt.Errorf("snapshot: %v", err), ErrorUnknown) 76 | } else { 77 | buf := new(bytes.Buffer) 78 | if err := jpeg.Encode(buf, snapshot.Image, nil); err != nil { 79 | resp = NewErrResponse(fmt.Errorf("encode: %v", err), ErrorUnknown) 80 | } else { 81 | resp = SnapshotResponse{ 82 | Data: &SnapshotResponseData{ 83 | Bytes: buf.Bytes(), 84 | }, 85 | } 86 | } 87 | } 88 | 89 | if err := WriteJSON(w, r, resp); err != nil { 90 | fmt.Println("responding failed", err) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /api/system.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "syscall" 7 | "time" 8 | ) 9 | 10 | // SystemRestartRequest is a request restart the system. 11 | type SystemRestartRequest struct { 12 | } 13 | 14 | // SystemRestartResponse is a response to a SystemRestartRequest. 15 | type SystemRestartResponse struct { 16 | Data SystemRestartResponseData `json:"data"` 17 | } 18 | 19 | // SystemRestartResponseData is the response data of a SystemRestartRequest. 20 | type SystemRestartResponseData struct { 21 | Success bool `json:"success"` 22 | } 23 | 24 | // SystemRestart triggers a system restart by terminating the app. 25 | func (a *Api) SystemRestart(w http.ResponseWriter, r *http.Request) { 26 | var resp = SystemRestartResponse{ 27 | Data: SystemRestartResponseData{ 28 | Success: true, 29 | }, 30 | } 31 | if err := WriteJSON(w, r, resp); err != nil { 32 | fmt.Println("responding failed", err) 33 | } 34 | 35 | go func() { 36 | // sleep so we can be sure that the client gets a response 37 | // before the process is killed 38 | time.Sleep(1 * time.Second) 39 | 40 | // SIGUSR1 has to be handled in main 41 | syscall.Kill(syscall.Getpid(), syscall.SIGUSR1) 42 | }() 43 | } 44 | 45 | // SystemHeartbeatRequest is a request check the availability of the system. 46 | type SystemHeartbeatRequest struct { 47 | } 48 | 49 | // SystemHeartbeatResponse is a response to a SystemHeartbeatRequest. 50 | type SystemHeartbeatResponse struct { 51 | Data SystemHeartbeatResponseData `json:"data"` 52 | } 53 | 54 | // SystemHeartbeatResponseData is the response data of a SystemHeartbeatRequest. 55 | type SystemHeartbeatResponseData struct { 56 | Success bool `json:"success"` 57 | } 58 | 59 | // SystemHeartbeat returns the system heartbeat. 60 | func (a *Api) SystemHeartbeat(w http.ResponseWriter, r *http.Request) { 61 | var resp = SystemHeartbeatResponse{ 62 | Data: SystemHeartbeatResponseData{ 63 | Success: true, 64 | }, 65 | } 66 | if err := WriteJSON(w, r, resp); err != nil { 67 | fmt.Println("responding failed", err) 68 | } 69 | } 70 | 71 | // SystemInfoRequest is a request check the system info. 72 | type SystemInfoRequest struct{} 73 | 74 | // SystemInfoResponse is a response to a SystemInfoRequest. 75 | type SystemInfoResponse struct { 76 | Data SystemInfoResponseData `json:"data"` 77 | } 78 | 79 | // SystemInfoResponseData is the response data of a SystemInfoRequest. 80 | type SystemInfoResponseData struct { 81 | Version string `json:"version"` 82 | Uptime float64 `json:"uptime"` 83 | } 84 | 85 | // SystemInfo returns the system info. 86 | func (a *Api) SystemInfo(w http.ResponseWriter, r *http.Request) { 87 | var resp = SystemInfoResponse{ 88 | Data: SystemInfoResponseData{ 89 | Version: a.App.Version, 90 | Uptime: time.Since(a.App.Launch).Seconds(), 91 | }, 92 | } 93 | 94 | if err := WriteJSON(w, r, resp); err != nil { 95 | fmt.Println("responding failed", err) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/blang/semver" 5 | "github.com/brutella/go-github-selfupdate/selfupdate" 6 | "github.com/brutella/hap" 7 | "github.com/brutella/hap/log" 8 | "github.com/brutella/hkcam/ffmpeg" 9 | 10 | "fmt" 11 | "os" 12 | "time" 13 | ) 14 | 15 | type App struct { 16 | BuildMode string 17 | BuildDate time.Time 18 | Version string 19 | Launch time.Time 20 | Store hap.Store 21 | FFMPEG ffmpeg.FFMPEG 22 | } 23 | 24 | func (app App) Restart() { 25 | log.Info.Println("restart not implemented yet") 26 | } 27 | 28 | // SemVersion returns the semantic version of the app. 29 | func (app App) SemVersion() (semver.Version, error) { 30 | return semver.ParseTolerant(app.Version) 31 | } 32 | 33 | func (app App) CheckForUpdate(pre bool) (up *Update, err error) { 34 | var av, rv semver.Version 35 | 36 | av, err = app.SemVersion() 37 | if err != nil { 38 | return 39 | } 40 | 41 | up, err = app.LatestVersion(pre) 42 | if err != nil { 43 | return 44 | } 45 | 46 | if up == nil { 47 | log.Debug.Println("check for update: no new version found") 48 | return 49 | } 50 | 51 | rv, err = semver.ParseTolerant(up.Version) 52 | if err != nil { 53 | log.Debug.Println("check for update:", err) 54 | return 55 | } 56 | 57 | if rv.LTE(av) { 58 | log.Debug.Printf("check for update: %s <= %s\n", rv, av) 59 | up = nil 60 | return 61 | } 62 | return 63 | } 64 | 65 | func (app App) LatestVersion(pre bool) (*Update, error) { 66 | upt, err := selfupdate.NewUpdater(selfupdate.Config{PreRelease: pre}) 67 | latest, found, err := upt.DetectLatest("brutella/hkcam") 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | if !found { 73 | log.Debug.Println("check for update: no version found") 74 | return nil, nil 75 | } 76 | 77 | update := &Update{} 78 | update.State = UpdateStateDefault 79 | update.Version = latest.Version.String() 80 | update.PreRelease = latest.PreRelease 81 | update.URL = latest.URL 82 | 83 | return update, nil 84 | } 85 | 86 | // InstallUpdate performs an update to the latest version. 87 | // If installing fails, the error is stored in up.Err. 88 | func (app *App) InstallUpdate(up *Update) error { 89 | up.State = UpdateStateInstall 90 | up.Err = nil 91 | 92 | cmdPath, err := os.Executable() 93 | if err != nil { 94 | log.Info.Println(err) 95 | up.State = UpdateStateFailure 96 | up.Err = err 97 | return err 98 | } 99 | 100 | upt, err := selfupdate.NewUpdater(selfupdate.Config{PreRelease: up.PreRelease}) 101 | if err != nil { 102 | log.Info.Println(err) 103 | up.State = UpdateStateFailure 104 | up.Err = err 105 | return err 106 | } 107 | 108 | re, found, err := upt.DetectVersion("brutella/hkcam", up.Version) 109 | if err != nil { 110 | log.Info.Println("search version:", err) 111 | 112 | // update failed 113 | up.State = UpdateStateFailure 114 | up.Err = err 115 | return err 116 | } else if !found { 117 | err := fmt.Errorf("version %s not found", up.Version) 118 | log.Info.Println(err) 119 | 120 | // update failed 121 | up.State = UpdateStateFailure 122 | up.Err = err 123 | return err 124 | } 125 | 126 | err = upt.UpdateTo(re, cmdPath) 127 | if err != nil { 128 | log.Info.Println("install update:", err) 129 | 130 | // update failed 131 | up.State = UpdateStateFailure 132 | up.Err = err 133 | return err 134 | } 135 | 136 | // store version and url of latest version 137 | up.Version = re.Version.String() 138 | up.URL = re.URL 139 | 140 | // update succeeded 141 | up.State = UpdateStateSuccess 142 | 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /app/update.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type UpdateState int 8 | 9 | const ( 10 | UpdateStateDefault UpdateState = iota 11 | UpdateStateInstall 12 | UpdateStateSuccess 13 | UpdateStateCancelled 14 | UpdateStateFailure 15 | ) 16 | 17 | type Update struct { 18 | State UpdateState `json:"state"` 19 | Version string `json:"version"` 20 | PreRelease bool `json:"pre"` 21 | URL string `json:"url"` 22 | CreatedAt time.Time `json:"created_at"` 23 | Err error `json:"error"` 24 | } 25 | 26 | func (u Update) Installing() bool { 27 | return u.State == UpdateStateInstall 28 | } 29 | 30 | func (u Update) Cancelled() bool { 31 | return u.State == UpdateStateCancelled 32 | } 33 | func (u Update) Failure() bool { 34 | return u.State == UpdateStateFailure 35 | } 36 | 37 | func (u Update) Success() bool { 38 | return u.State == UpdateStateSuccess 39 | } 40 | -------------------------------------------------------------------------------- /assets.go: -------------------------------------------------------------------------------- 1 | package hkcam 2 | 3 | import ( 4 | "github.com/brutella/hap/characteristic" 5 | ) 6 | 7 | // TypeAssets is the uuid of the Assets characteristic 8 | const TypeAssets = "ACD9DFE7-948D-43D0-A205-D2F6F368541D" 9 | 10 | // Assets contains a list of assets encoded as JSON. 11 | // A valid JSON looks like this. `{"assets":[{"id":"1.jpg", "date":"2019-04-01T10:00:00+00:00"}]}` 12 | // Writing to this characteristic is discouraged. 13 | type Assets struct { 14 | *characteristic.Bytes 15 | } 16 | 17 | func NewAssets() *Assets { 18 | b := characteristic.NewBytes(TypeAssets) 19 | b.Permissions = []string{characteristic.PermissionRead, characteristic.PermissionEvents} 20 | 21 | b.SetValue([]byte{}) 22 | 23 | return &Assets{b} 24 | } 25 | 26 | type AssetsMetadataResponse struct { 27 | Assets []CameraAssetMetadata `json:"assets"` 28 | } 29 | 30 | type CameraAssetMetadata struct { 31 | ID string `json:"id"` 32 | Date string `json:"date"` 33 | } 34 | -------------------------------------------------------------------------------- /camera_control.go: -------------------------------------------------------------------------------- 1 | package hkcam 2 | 3 | import ( 4 | "github.com/brutella/hap/log" 5 | "github.com/nfnt/resize" 6 | "github.com/radovskyb/watcher" 7 | 8 | "bytes" 9 | "encoding/json" 10 | "fmt" 11 | "image" 12 | "image/jpeg" 13 | "io/ioutil" 14 | "os" 15 | "path/filepath" 16 | "regexp" 17 | "time" 18 | ) 19 | 20 | const TypeCameraControl = "19BDAD9E-6102-48D5-B413-3F11253706AE" 21 | 22 | // RefDate represents the reference date used to generate asset ids. 23 | // Short ids are preferred and therefore we use 1st April 2019 as the reference date. 24 | var RefDate = time.Date(2019, 4, 1, 0, 0, 0, 0, time.UTC) 25 | 26 | type CameraControl struct { 27 | TakeSnapshot *TakeSnapshot 28 | Assets *Assets 29 | GetAsset *GetAsset 30 | DeleteAssets *DeleteAssets 31 | 32 | CameraSnapshotReq func(width, height uint) (*image.Image, error) 33 | 34 | snapshots []*snapshot 35 | w *watcher.Watcher 36 | } 37 | 38 | func NewCameraControl() *CameraControl { 39 | cc := CameraControl{} 40 | 41 | cc.TakeSnapshot = NewTakeSnapshot() 42 | cc.Assets = NewAssets() 43 | cc.GetAsset = NewGetAsset() 44 | cc.DeleteAssets = NewDeleteAssets() 45 | 46 | return &cc 47 | } 48 | 49 | func (cc *CameraControl) SetupWithDir(dir string) { 50 | r := regexp.MustCompile(".*\\.jpg") 51 | 52 | fs, err := ioutil.ReadDir(dir) 53 | if err != nil { 54 | log.Info.Println(err) 55 | } 56 | 57 | for _, f := range fs { 58 | if r.MatchString(f.Name()) == false { 59 | continue 60 | } 61 | path := filepath.Join(dir, f.Name()) 62 | b, err := ioutil.ReadFile(path) 63 | if err != nil { 64 | log.Info.Println(f, err) 65 | } else { 66 | s := snapshot{ 67 | ID: f.Name(), 68 | Date: f.ModTime().Format(time.RFC3339), 69 | Bytes: b, 70 | Path: path, 71 | } 72 | cc.add(&s) 73 | } 74 | } 75 | cc.updateAssetsCharacteristic() 76 | 77 | go cc.watch(dir, r) 78 | 79 | cc.GetAsset.OnValueRemoteUpdate(func(buf []byte) { 80 | var req GetAssetRequest 81 | err := json.Unmarshal(buf, &req) 82 | if err != nil { 83 | log.Debug.Fatalln("GetAssetRequest:", err) 84 | } 85 | 86 | for _, s := range cc.snapshots { 87 | if s.ID == req.ID { 88 | r := bytes.NewReader(s.Bytes) 89 | img, err := jpeg.Decode(r) 90 | if err != nil { 91 | log.Info.Printf("jpeg.Decode() %v", err) 92 | cc.GetAsset.SetValue([]byte{}) 93 | return 94 | } 95 | 96 | scaled := resize.Resize(req.Width, req.Height, img, resize.Lanczos3) 97 | imgBuf := new(bytes.Buffer) 98 | if err := jpeg.Encode(imgBuf, scaled, nil); err != nil { 99 | log.Info.Printf("jpeg.Encode() %v", err) 100 | cc.GetAsset.SetValue([]byte{}) 101 | return 102 | } 103 | 104 | cc.GetAsset.SetValue(imgBuf.Bytes()) 105 | return 106 | } 107 | } 108 | }) 109 | 110 | cc.DeleteAssets.OnValueRemoteUpdate(func(buf []byte) { 111 | var req DeleteAssetsRequest 112 | err := json.Unmarshal(buf, &req) 113 | if err != nil { 114 | log.Debug.Fatalln("GetAssetRequest:", err) 115 | return 116 | } 117 | 118 | for _, id := range req.IDs { 119 | err = cc.deleteWithID(id) 120 | if err != nil { 121 | log.Debug.Println("delete:", err) 122 | } 123 | } 124 | }) 125 | 126 | cc.TakeSnapshot.OnValueRemoteUpdate(func(v bool) { 127 | if v == true { 128 | img, err := cc.CameraSnapshotReq(1920, 1080) 129 | if err != nil { 130 | log.Info.Println(err) 131 | } else { 132 | name := fmt.Sprintf("%.0f.jpg", time.Now().Sub(RefDate).Seconds()) 133 | path := filepath.Join(dir, name) 134 | 135 | buf := new(bytes.Buffer) 136 | if err := jpeg.Encode(buf, *img, nil); err != nil { 137 | log.Debug.Printf("jpeg.Encode() %v", err) 138 | } else { 139 | ioutil.WriteFile(path, buf.Bytes(), os.ModePerm) 140 | } 141 | } 142 | 143 | // Disable shutter after some timeout 144 | go func() { 145 | <-time.After(1 * time.Second) 146 | cc.TakeSnapshot.SetValue(false) 147 | }() 148 | } 149 | }) 150 | } 151 | 152 | func (cc *CameraControl) add(s *snapshot) { 153 | log.Debug.Println("add:", s.ID) 154 | cc.snapshots = append(cc.snapshots, s) 155 | } 156 | 157 | func (cc *CameraControl) deleteWithID(id string) error { 158 | log.Debug.Println("del:", id) 159 | for _, s := range cc.snapshots { 160 | if s.ID == id { 161 | return os.Remove(s.Path) 162 | } 163 | } 164 | 165 | return fmt.Errorf("File with id %s not found", id) 166 | } 167 | 168 | func (cc *CameraControl) removeWithID(id string) { 169 | log.Debug.Println("rmv:", id) 170 | for i, s := range cc.snapshots { 171 | if s.ID == id { 172 | cc.snapshots = append(cc.snapshots[:i], cc.snapshots[i+1:]...) 173 | return 174 | } 175 | } 176 | } 177 | 178 | func (cc *CameraControl) updateAssetsCharacteristic() { 179 | assets := []CameraAssetMetadata{} 180 | for _, s := range cc.snapshots { 181 | asset := CameraAssetMetadata{ 182 | ID: s.ID, 183 | Date: s.Date, 184 | } 185 | assets = append(assets, asset) 186 | } 187 | 188 | p := AssetsMetadataResponse{ 189 | Assets: assets, 190 | } 191 | if b, err := json.Marshal(p); err != nil { 192 | log.Info.Println(err) 193 | } else { 194 | log.Debug.Println(string(b)) 195 | cc.Assets.SetValue(b) 196 | } 197 | } 198 | 199 | func (cc *CameraControl) watch(dir string, r *regexp.Regexp) { 200 | w := watcher.New() 201 | w.FilterOps(watcher.Create, watcher.Remove) 202 | w.AddFilterHook(watcher.RegexFilterHook(r, false)) 203 | 204 | go func() { 205 | for { 206 | select { 207 | case event := <-w.Event: 208 | switch event.Op { 209 | case watcher.Create: 210 | b, err := ioutil.ReadFile(event.Path) 211 | if err != nil { 212 | log.Info.Println(event.Path, err) 213 | } else { 214 | s := snapshot{ 215 | ID: event.Name(), 216 | Date: event.ModTime().Format(time.RFC3339), 217 | Bytes: b, 218 | Path: event.Path, 219 | } 220 | cc.add(&s) 221 | } 222 | case watcher.Remove: 223 | cc.removeWithID(event.Name()) 224 | default: 225 | break 226 | } 227 | 228 | cc.updateAssetsCharacteristic() 229 | 230 | case err := <-w.Error: 231 | log.Info.Fatalln(err) 232 | case <-w.Closed: 233 | return 234 | } 235 | } 236 | }() 237 | 238 | if err := w.Add(dir); err != nil { 239 | log.Info.Fatalln(err) 240 | } 241 | 242 | if err := w.Start(time.Second * 1); err != nil { 243 | log.Info.Fatalln(err) 244 | } 245 | } 246 | 247 | type snapshot struct { 248 | ID string 249 | Date string 250 | Bytes []byte 251 | Path string 252 | } 253 | -------------------------------------------------------------------------------- /cmd/hkcam/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/brutella/hap" 5 | "github.com/brutella/hap/accessory" 6 | "github.com/brutella/hap/log" 7 | "github.com/brutella/hkcam" 8 | "github.com/brutella/hkcam/api" 9 | "github.com/brutella/hkcam/app" 10 | "github.com/brutella/hkcam/ffmpeg" 11 | "github.com/brutella/hkcam/html" 12 | "github.com/unrolled/render" 13 | 14 | "bytes" 15 | "context" 16 | "encoding/json" 17 | "flag" 18 | "fmt" 19 | "html/template" 20 | "image" 21 | "image/jpeg" 22 | "io/ioutil" 23 | "net/http" 24 | "os" 25 | "os/signal" 26 | "path/filepath" 27 | "runtime" 28 | "syscall" 29 | "time" 30 | ) 31 | 32 | var ( 33 | // Date is build date. 34 | Date string 35 | 36 | // Version is the app version. 37 | Version string 38 | 39 | // BuildMode is the build mode ("debug", "release") 40 | BuildMode string 41 | ) 42 | 43 | const ( 44 | DateLayout = "2006-01-02T15:04:05Z-0700" 45 | ) 46 | 47 | func main() { 48 | 49 | // Platform dependent flags 50 | var inputDevice *string 51 | var inputFilename *string 52 | var loopbackFilename *string 53 | var h264Encoder *string 54 | var h264Decoder *string 55 | 56 | if runtime.GOOS == "linux" { 57 | inputDevice = flag.String("input_device", "v4l2", "video input device") 58 | inputFilename = flag.String("input_filename", "/dev/video0", "video input device filename") 59 | loopbackFilename = flag.String("loopback_filename", "/dev/video99", "video loopback device filename") 60 | h264Decoder = flag.String("h264_decoder", "", "h264 video decoder") 61 | h264Encoder = flag.String("h264_encoder", "h264_v4l2m2m", "h264 video encoder") 62 | } else if runtime.GOOS == "darwin" { // macOS 63 | inputDevice = flag.String("input_device", "avfoundation", "video input device") 64 | inputFilename = flag.String("input_filename", "default", "video input device filename") 65 | // loopback is not needed on macOS because avfoundation provides multi-access to the camera 66 | loopbackFilename = flag.String("loopback_filename", "", "video loopback device filename") 67 | h264Decoder = flag.String("h264_decoder", "", "h264 video decoder") 68 | h264Encoder = flag.String("h264_encoder", "h264_videotoolbox", "h264 video encoder") 69 | } else { 70 | log.Info.Fatalf("%s platform is not supported", runtime.GOOS) 71 | } 72 | 73 | var minVideoBitrate *int = flag.Int("min_video_bitrate", 0, "minimum video bit rate in kbps") 74 | var multiStream *bool = flag.Bool("multi_stream", false, "Allow multiple clients to view the stream simultaneously") 75 | var dataDir *string = flag.String("data_dir", "db", "Path to data directory") 76 | var verbose *bool = flag.Bool("verbose", false, "Verbose logging") 77 | var pin *string = flag.String("pin", "00102003", "PIN for HomeKit pairing") 78 | var port *string = flag.String("port", "", "Port on which transport is reachable") 79 | 80 | flag.Parse() 81 | 82 | if *verbose { 83 | log.Debug.Enable() 84 | ffmpeg.EnableVerboseLogging() 85 | } 86 | 87 | buildDate, err := time.Parse(DateLayout, Date) 88 | if err != nil { 89 | log.Info.Fatal(err) 90 | } 91 | 92 | log.Info.Printf("version %s (built at %s)\n", Version, Date) 93 | 94 | switchInfo := accessory.Info{Name: "Camera", Firmware: Version, Manufacturer: "Matthias Hochgatterer"} 95 | cam := accessory.NewCamera(switchInfo) 96 | 97 | cfg := ffmpeg.Config{ 98 | InputDevice: *inputDevice, 99 | InputFilename: *inputFilename, 100 | LoopbackFilename: *loopbackFilename, 101 | H264Decoder: *h264Decoder, 102 | H264Encoder: *h264Encoder, 103 | MinVideoBitrate: *minVideoBitrate, 104 | MultiStream: *multiStream, 105 | } 106 | 107 | ffmpeg := hkcam.SetupFFMPEGStreaming(cam, cfg) 108 | 109 | // Add a custom camera control service to record snapshots 110 | cc := hkcam.NewCameraControl() 111 | cam.Control.AddC(cc.Assets.C) 112 | cam.Control.AddC(cc.GetAsset.C) 113 | cam.Control.AddC(cc.DeleteAssets.C) 114 | cam.Control.AddC(cc.TakeSnapshot.C) 115 | 116 | store := hap.NewFsStore(*dataDir) 117 | s, err := hap.NewServer(store, cam.A) 118 | if err != nil { 119 | log.Info.Panic(err) 120 | } 121 | 122 | s.Pin = *pin 123 | s.Addr = fmt.Sprintf(":%s", *port) 124 | 125 | s.ServeMux().HandleFunc("/resource", func(res http.ResponseWriter, req *http.Request) { 126 | if !s.IsAuthorized(req) { 127 | hap.JsonError(res, hap.JsonStatusInsufficientPrivileges) 128 | return 129 | } 130 | 131 | if req.Method != http.MethodPost { 132 | res.WriteHeader(http.StatusBadRequest) 133 | return 134 | } 135 | 136 | body, err := ioutil.ReadAll(req.Body) 137 | if err != nil { 138 | log.Info.Println(err) 139 | res.WriteHeader(http.StatusInternalServerError) 140 | return 141 | } 142 | 143 | r := struct { 144 | Type string `json:"resource-type"` 145 | Width uint `json:"image-width"` 146 | Height uint `json:"image-height"` 147 | }{} 148 | 149 | err = json.Unmarshal(body, &r) 150 | if err != nil { 151 | log.Info.Println(err) 152 | res.WriteHeader(http.StatusBadRequest) 153 | return 154 | } 155 | 156 | log.Debug.Printf("%+v\n", r) 157 | 158 | switch r.Type { 159 | case "image": 160 | b, err := snapshot(r.Width, r.Height, ffmpeg) 161 | if err != nil { 162 | log.Info.Println(err) 163 | res.WriteHeader(http.StatusInternalServerError) 164 | return 165 | } 166 | 167 | res.Header().Set("Content-Type", "image/jpeg") 168 | wr := hap.NewChunkedWriter(res, 2048) 169 | wr.Write(b) 170 | default: 171 | log.Info.Printf("unsupported resource request \"%s\"\n", r.Type) 172 | res.WriteHeader(http.StatusInternalServerError) 173 | return 174 | } 175 | }) 176 | 177 | cc.SetupWithDir(*dataDir) 178 | cc.CameraSnapshotReq = func(width, height uint) (*image.Image, error) { 179 | snapshot, err := ffmpeg.Snapshot(width, height) 180 | if err != nil { 181 | return nil, err 182 | } 183 | 184 | return &snapshot.Image, nil 185 | } 186 | 187 | appl := &app.App{ 188 | BuildMode: BuildMode, 189 | BuildDate: buildDate, 190 | Version: Version, 191 | Launch: time.Now(), 192 | Store: store, 193 | FFMPEG: ffmpeg, 194 | } 195 | api := &api.Api{ 196 | App: appl, 197 | } 198 | 199 | // files are served via fs.go 200 | fs := &embedFS{} 201 | 202 | funcs := template.FuncMap{ 203 | "_formatDate": func(d time.Time) string { return d.Format(time.RFC3339) }, 204 | "_safeHTML": func(s string) template.HTML { return template.HTML(s) }, 205 | "T": func(s string, args ...interface{}) string { return fmt.Sprintf(s, args...) }, 206 | } 207 | html := html.Html{ 208 | Store: store, 209 | BuildMode: BuildMode, 210 | Api: api, 211 | App: appl, 212 | FileSystem: fs, 213 | Render: render.New(render.Options{ 214 | Directory: "/html/tmpl", 215 | FileSystem: fs, 216 | Layout: "layout", 217 | Funcs: []template.FuncMap{funcs}, 218 | }), 219 | } 220 | 221 | s.ServeMux().Mount("/api", api.Router()) 222 | s.ServeMux().Mount("/", html.Router()) 223 | 224 | // serve static files 225 | staticFs := http.FileServer(FS(false)) 226 | s.ServeMux().HandleFunc("/static/*", func(w http.ResponseWriter, r *http.Request) { 227 | staticFs.ServeHTTP(w, r) 228 | }) 229 | 230 | c := make(chan os.Signal) 231 | signal.Notify(c, os.Interrupt) 232 | signal.Notify(c, syscall.SIGTERM) 233 | signal.Notify(c, syscall.SIGUSR1) 234 | 235 | ctx, cancel := context.WithCancel(context.Background()) 236 | go func() { 237 | <-c 238 | signal.Stop(c) // stop delivering signals 239 | cancel() 240 | }() 241 | 242 | if err := s.ListenAndServe(ctx); err != nil { 243 | if err != ctx.Err() { 244 | log.Info.Println(err) 245 | } 246 | } 247 | } 248 | 249 | func snapshot(width, height uint, ffmpeg ffmpeg.FFMPEG) ([]byte, error) { 250 | log.Debug.Printf("snapshot %dw x %dh\n", width, height) 251 | 252 | snapshot, err := ffmpeg.Snapshot(width, height) 253 | if err != nil { 254 | return nil, fmt.Errorf("snapshot: %v", err) 255 | } 256 | 257 | buf := new(bytes.Buffer) 258 | if err := jpeg.Encode(buf, snapshot.Image, nil); err != nil { 259 | return nil, fmt.Errorf("encode: %v", err) 260 | } 261 | 262 | return buf.Bytes(), nil 263 | } 264 | 265 | // embedFS serves files 266 | type embedFS struct { 267 | } 268 | 269 | func (embedFS) Walk(root string, walkFn filepath.WalkFunc) error { 270 | for path, file := range _escData { 271 | stat, err := file.Stat() 272 | err = walkFn(path, stat, err) 273 | if err != nil { 274 | return err 275 | } 276 | } 277 | 278 | return nil 279 | } 280 | 281 | func (embedFS) ReadFile(filename string) ([]byte, error) { 282 | return FSByte(false, filename) 283 | } 284 | -------------------------------------------------------------------------------- /delete_assets.go: -------------------------------------------------------------------------------- 1 | package hkcam 2 | 3 | import ( 4 | "github.com/brutella/hap/characteristic" 5 | ) 6 | 7 | // TypeDeleteAssets is the uuid of the DeleteAssets characteristic 8 | const TypeDeleteAssets = "3982EB69-1ECE-463E-96C6-E5A7DF2FA1CD" 9 | 10 | // DeleteAssets is used to handle request to delete assets. 11 | // A valid JSON looks like this. `{"ids":["1.jpg"]}` 12 | // Reading the value of this characteristic is discouraged. 13 | type DeleteAssets struct { 14 | *characteristic.Bytes 15 | } 16 | 17 | func NewDeleteAssets() *DeleteAssets { 18 | b := characteristic.NewBytes(TypeDeleteAssets) 19 | b.Permissions = []string{characteristic.PermissionRead, characteristic.PermissionWrite} 20 | b.SetValue([]byte{}) 21 | 22 | return &DeleteAssets{b} 23 | } 24 | 25 | type DeleteAssetsRequest struct { 26 | IDs []string `json:"ids"` 27 | } 28 | -------------------------------------------------------------------------------- /enclosure/Block.STL: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/Block.STL -------------------------------------------------------------------------------- /enclosure/Body.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/Body.stl -------------------------------------------------------------------------------- /enclosure/Body_support.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/Body_support.stl -------------------------------------------------------------------------------- /enclosure/Bolt.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/Bolt.stl -------------------------------------------------------------------------------- /enclosure/Lid.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/Lid.stl -------------------------------------------------------------------------------- /enclosure/Nut.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/Nut.stl -------------------------------------------------------------------------------- /enclosure/README.md: -------------------------------------------------------------------------------- 1 | # Enclosure 2 | 3 | The enclosure can be printed with any 3D printer. It can be assembled without any screws. You can put the enclosure on a desk or mount it on a wall. 4 | 5 | ## Printer Settings 6 | 7 | I print all parts on my Prusa MK3 with PLA. I'm using a `0.20mm` layer height and `20%` infill. Less infill will probably work too. 8 | 9 | **HKCam Enclosure** 10 | 11 | - [Body_support.stl](./Body_support.stl) – housing for a Raspberry Pi Zero W and camera module (with support) 12 | - [Lid.stl](./Lid.stl) – lid which snap-fits onto the body 13 | - [Stand.stl](./Stand.stl) – a stand which can be attached to the body at the bottom 14 | - [Wall Mount.stl](./WallMount.stl) – wall mount which can be used in combination with brackets of the [Articulating Raspberry Pi Camera Mount](https://www.thingiverse.com/thing:3114849) 15 | 16 | **Articulating Raspberry Pi Camera Mount Parts** 17 | 18 | - [Nut.stl](./Nut.stl) and [Bolt.stl](./Bolt.stl) 19 | - [ffLink_90_support.stl](./ffLink_90_support.stl) – female-to-female link (with support) 20 | - [mfLink_90_support.stl](./mfLink_90_support.stl) – 90° male-to-female link (with support) 21 | - [mfLink_support.stl](./mfLink_support.stl) – male-to-female link (with support) 22 | - [Block.stl](./Block.stl) – Block 23 | 24 | > [Articulating Raspberry Pi Camera Mount for Prusa MK3 and MK2](https://www.thingiverse.com/thing:3114849) by [sneaks](https://www.thingiverse.com/sneaks) is licensed under the [Creative Commons - Attribution](http://creativecommons.org/licenses/by/3.0/) license. 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 44 | 45 | 46 | 47 | 59 | 60 | 61 |
Enclosure Examples
Parts
Desk mount 38 |
    39 |
  • 1 Body in black
  • 40 |
  • 1 Lid in yellow
  • 41 |
  • 1 Stand in black
  • 42 |
43 |
Wall mount 48 |
    49 |
  • 1 Body in black
  • 50 |
  • 1 Lid in yellow
  • 51 |
  • 1 Wall Mount in black
  • 52 |
  • 3 Bolts in yellow
  • 53 |
  • 2 Nuts in yellow
  • 54 |
  • 1 mfLink_90_support in black
  • 55 |
  • 1 ffLink_90_support in black
  • 56 |
  • 1 Block in black
  • 57 |
58 |
-------------------------------------------------------------------------------- /enclosure/Stand.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/Stand.stl -------------------------------------------------------------------------------- /enclosure/WallMount.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/WallMount.stl -------------------------------------------------------------------------------- /enclosure/all.shapr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/all.shapr -------------------------------------------------------------------------------- /enclosure/enclosure_gopro.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/enclosure_gopro.stl -------------------------------------------------------------------------------- /enclosure/ffLink_90_support.STL: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/ffLink_90_support.STL -------------------------------------------------------------------------------- /enclosure/mfLink_90_support.STL: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/mfLink_90_support.STL -------------------------------------------------------------------------------- /enclosure/mfLink_support.STL: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brutella/hkcam/959d66471bc0d76859a82d429e9077061f4dde9f/enclosure/mfLink_support.STL -------------------------------------------------------------------------------- /ffmpeg/README.md: -------------------------------------------------------------------------------- 1 | # Debugging 2 | 3 | ffmpeg can be used to debug RTSP streaming by using ffmpeg and ffplay. 4 | Install ffmpeg and ffplay via `brew install ffmpeg --with-ffplay`. 5 | 6 | ## RTSP Streaming 7 | 8 | 1. Create an RTSP stream 9 | 10 | 1.1. Raspberry Pi 11 | ```sh 12 | ffmpeg -re -f video4linux2 -i /dev/video0 -map 0:0 -vcodec h264_omx -pix_fmt yuv420p -r 20 -f rawvideo -tune zerolatency -b:v 1500k -bufsize 1500k -payload_type 99 -ssrc 16132552 -f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params omz31e5SiZSneUySvSsIaFfu+NW2uWUl9+FHs3HD "srtp://192.168.0.14:58536?rtcpport=58536&localrtcpport=58536&pkt_size=1378" 13 | ``` 14 | 15 | 1.2. Mac 16 | ```sh 17 | ffmpeg -re -f avfoundation -i "1" -map 0:0 -vcodec libx264 -pix_fmt yuv420p -r 20 -f rawvideo -tune zerolatency -b:v 1500k -bufsize 1500k -payload_type 99 -ssrc 16132552 -f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params omz31e5SiZSneUySvSsIaFfu+NW2uWUl9+FHs3HD "srtp://192.168.0.14:58536?rtcpport=58536&localrtcpport=58536&pkt_size=1378" 18 | ``` 19 | 20 | 2. Create sdp file on receiver 21 | ``` 22 | v=0 23 | o=- 0 0 IN IP4 127.0.0.1 24 | s=No Name 25 | c=IN IP4 192.168.0.14 26 | t=0 0 27 | a=tool:libavformat 58.17.101 28 | m=video 58536 RTP/AVP 99 29 | b=AS:300 30 | a=rtpmap:99 H264/90000 31 | a=fmtp:99 packetization-mode=1 32 | a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:omz31e5SiZSneUySvSsIaFfu+NW2uWUl9+FHs3HD 33 | ``` 34 | 35 | 3. Receive stream 36 | ```sh 37 | ffplay -i -protocol_whitelist file,udp,rtp 38 | ``` 39 | 40 | ## Bitrate 41 | 42 | ffmpeg cannot set the bitrate on Raspberry Pi, we have to do it manually with `v4l2-ctl --set-ctrl video_bitrate=300000`. 43 | 44 | ## Streaming issues 45 | 46 | ffmpeg only sends one H264 keyframe at the beginning of a RTP stream on the RPi because of https://video.stackexchange.com/a/21245 47 | If we open the stream after it has been started using ffplay, we missed the keyframe and never get one – results in error: decode_slice_header error. 48 | 49 | Therefore, we have to start ffplay before starting streaming. -------------------------------------------------------------------------------- /ffmpeg/config.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | // Config contains ffmpeg parameters 4 | type Config struct { 5 | InputDevice string 6 | InputFilename string 7 | LoopbackFilename string 8 | H264Decoder string 9 | H264Encoder string 10 | MinVideoBitrate int 11 | MultiStream bool 12 | } 13 | -------------------------------------------------------------------------------- /ffmpeg/doc.go: -------------------------------------------------------------------------------- 1 | // Package ffmpeg lets you access the camera via ffmpeg to stream video and to create snapshots. 2 | // 3 | // This package requires the `ffmpeg` command line tool to be installed. Install by running 4 | // - `apt-get install ffmpeg` on linux 5 | // - `brew install ffmpeg` on macOS 6 | // 7 | // HomeKit supports multiple video codecs but h264 is mandatory. So make sure that a h264 decoder for ffmpeg is installed too. 8 | // Audio streaming is currently not supported. 9 | // 10 | // If you are running a RPi with Raspbian, it is recommended to use a v4l2 loopback device instead of access the camera via `/dev/video0` directly. 11 | package ffmpeg 12 | -------------------------------------------------------------------------------- /ffmpeg/ffmpeg.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "sync" 8 | 9 | "github.com/brutella/hap/log" 10 | "github.com/brutella/hap/rtp" 11 | ) 12 | 13 | // StreamID is the type of the stream identifier 14 | type StreamID string 15 | 16 | // FFMPEG lets you interact with camera stream. 17 | type FFMPEG interface { 18 | PrepareNewStream(rtp.SetupEndpoints, rtp.SetupEndpointsResponse) StreamID 19 | Start(StreamID, rtp.VideoParameters, rtp.AudioParameters) error 20 | Stop(StreamID) 21 | Suspend(StreamID) 22 | Resume(StreamID) 23 | ActiveStreams() int 24 | Reconfigure(StreamID, rtp.VideoParameters, rtp.AudioParameters) error 25 | Snapshot(width, height uint) (*Snapshot, error) 26 | RecentSnapshot(width, height uint) *Snapshot 27 | } 28 | 29 | var Stdout = ioutil.Discard 30 | var Stderr = ioutil.Discard 31 | 32 | // EnableVerboseLogging enables verbose logging of ffmpeg to stdout. 33 | func EnableVerboseLogging() { 34 | Stdout = os.Stdout 35 | Stderr = os.Stderr 36 | } 37 | 38 | type ffmpeg struct { 39 | cfg Config 40 | loop *loopback 41 | mutex *sync.Mutex 42 | streams map[StreamID]*stream 43 | snapshot *Snapshot 44 | } 45 | 46 | // New returns a new ffmpeg handle to start and stop video streams and to make snapshots. 47 | // If cfg specifies a video loopback, ffmpeg configures a loopback to support simultaneous access to the video device. 48 | func New(cfg Config) *ffmpeg { 49 | var loop *loopback = nil 50 | if cfg.LoopbackFilename != "" { 51 | loop = NewLoopback(cfg.InputDevice, cfg.InputFilename, cfg.LoopbackFilename) 52 | } 53 | 54 | return &ffmpeg{ 55 | cfg: cfg, 56 | loop: loop, 57 | mutex: &sync.Mutex{}, 58 | streams: make(map[StreamID]*stream, 0), 59 | } 60 | } 61 | 62 | func (f *ffmpeg) PrepareNewStream(req rtp.SetupEndpoints, resp rtp.SetupEndpointsResponse) StreamID { 63 | f.mutex.Lock() 64 | defer f.mutex.Unlock() 65 | 66 | id := StreamID(req.SessionId) 67 | s := &stream{f.videoInputDevice(), f.videoInputFilename(), f.cfg.H264Decoder, f.cfg.H264Encoder, f.cfg.MinVideoBitrate, req, resp, nil} 68 | f.streams[id] = s 69 | return id 70 | } 71 | 72 | func (f *ffmpeg) ActiveStreams() int { 73 | f.mutex.Lock() 74 | defer f.mutex.Unlock() 75 | 76 | return len(f.streams) 77 | } 78 | 79 | func (f *ffmpeg) Start(id StreamID, video rtp.VideoParameters, audio rtp.AudioParameters) error { 80 | f.mutex.Lock() 81 | defer f.mutex.Unlock() 82 | 83 | s, err := f.getStream(id) 84 | if err != nil { 85 | log.Info.Println("start:", err) 86 | return err 87 | } 88 | 89 | f.startLoopback() 90 | 91 | return s.start(video, audio) 92 | } 93 | 94 | func (f *ffmpeg) Stop(id StreamID) { 95 | f.mutex.Lock() 96 | defer f.mutex.Unlock() 97 | 98 | s, err := f.getStream(id) 99 | if err != nil { 100 | log.Info.Println("stop:", err) 101 | return 102 | } 103 | 104 | s.stop() 105 | delete(f.streams, id) 106 | 107 | if f.loop != nil { 108 | for _, s := range f.streams { 109 | if s.isActive() { 110 | log.Debug.Printf("Active sessions %v\n", f.streams) 111 | return 112 | } 113 | } 114 | 115 | // Stop loopback if no stream is active anymore 116 | f.loop.Stop() 117 | } 118 | } 119 | 120 | func (f *ffmpeg) Suspend(id StreamID) { 121 | f.mutex.Lock() 122 | defer f.mutex.Unlock() 123 | 124 | if s, err := f.getStream(id); err != nil { 125 | log.Info.Println("suspend:", err) 126 | } else { 127 | s.suspend() 128 | } 129 | } 130 | 131 | func (f *ffmpeg) Resume(id StreamID) { 132 | f.mutex.Lock() 133 | defer f.mutex.Unlock() 134 | 135 | if s, err := f.getStream(id); err != nil { 136 | log.Info.Println("resume:", err) 137 | } else { 138 | s.resume() 139 | } 140 | } 141 | 142 | func (f *ffmpeg) Reconfigure(id StreamID, video rtp.VideoParameters, audio rtp.AudioParameters) error { 143 | f.mutex.Lock() 144 | defer f.mutex.Unlock() 145 | 146 | s, err := f.getStream(id) 147 | if err != nil { 148 | log.Info.Println("reconfigure:", err) 149 | return err 150 | } 151 | 152 | return s.reconfigure(video, audio) 153 | } 154 | 155 | func (f *ffmpeg) getStream(id StreamID) (*stream, error) { 156 | if s, ok := f.streams[id]; ok { 157 | return s, nil 158 | } 159 | 160 | return nil, &StreamNotFoundError{id} 161 | } 162 | 163 | func (f *ffmpeg) startLoopback() { 164 | if f.loop != nil { 165 | if err := f.loop.Start(); err != nil { 166 | log.Info.Println("starting loopback failed:", err) 167 | } 168 | } 169 | } 170 | 171 | func (f *ffmpeg) RecentSnapshot(width, height uint) *Snapshot { 172 | f.mutex.Lock() 173 | defer f.mutex.Unlock() 174 | 175 | return f.snapshot 176 | } 177 | 178 | func (f *ffmpeg) Snapshot(width, height uint) (*Snapshot, error) { 179 | f.mutex.Lock() 180 | defer f.mutex.Unlock() 181 | 182 | f.startLoopback() 183 | 184 | shot, err := snapshot(width, height, f.videoInputDevice(), f.videoInputFilename()) 185 | f.snapshot = shot 186 | 187 | if f.loop != nil { 188 | for _, s := range f.streams { 189 | if s.isActive() { 190 | log.Debug.Printf("Active sessions %v\n", f.streams) 191 | return shot, err 192 | } 193 | } 194 | 195 | // Stop loopback if no stream is active anymore 196 | f.loop.Stop() 197 | } 198 | 199 | return shot, err 200 | } 201 | 202 | func (f *ffmpeg) videoInputDevice() string { 203 | return f.cfg.InputDevice 204 | } 205 | 206 | func (f *ffmpeg) videoInputFilename() string { 207 | if f.cfg.LoopbackFilename != "" { 208 | return f.cfg.LoopbackFilename 209 | } 210 | 211 | return f.cfg.InputFilename 212 | } 213 | 214 | type StreamNotFoundError struct { 215 | id StreamID 216 | } 217 | 218 | func (e *StreamNotFoundError) Error() string { 219 | return fmt.Sprintf("StreamID(%v) not found", []byte(e.id)) 220 | } 221 | -------------------------------------------------------------------------------- /ffmpeg/loopback.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "io" 7 | "os/exec" 8 | "strings" 9 | "sync" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/brutella/hap/log" 14 | ) 15 | 16 | // loopback copies data from the inpute filename to the loopback filename. 17 | // This is needed to provide simultaneous access to a v4l2 device. 18 | // On a Raspberry Pi you can create a video loopback device at /dev/video1 via [v4l2loopback](https://github.com/umlaeute/v4l2loopback). 19 | // The data from /dev/video0 is then copied to /dev/video1 via `ffmpeg -f v4l2 -i /dev/video0 -codec:v copy -f v4l2 /dev/video1`. 20 | // The loopback device can then be access simultaneously from multiple ffmpeg processes. 21 | type loopback struct { 22 | inputDevice string 23 | inputFilename string 24 | h264Decoder string 25 | loopbackFilename string 26 | 27 | mutex *sync.Mutex 28 | cmd *exec.Cmd 29 | out io.ReadWriter 30 | } 31 | 32 | // NewLoopback returns a new video loopback. 33 | func NewLoopback(inputDevice, inputFilename, loopbackFilename string) *loopback { 34 | return &loopback{ 35 | inputDevice: inputDevice, 36 | inputFilename: inputFilename, 37 | loopbackFilename: loopbackFilename, 38 | mutex: &sync.Mutex{}, 39 | } 40 | } 41 | 42 | // Start starts the loopback. 43 | // This method waits until the ffmpeg process is running. 44 | func (l *loopback) Start() error { 45 | l.mutex.Lock() 46 | defer l.mutex.Unlock() 47 | 48 | if l.cmd == nil { 49 | log.Debug.Println("Starting loopback") 50 | cmd := l.execCmd() 51 | pr, pw := io.Pipe() 52 | // cmd.Stdout = pw 53 | cmd.Stderr = pw 54 | 55 | if err := cmd.Start(); err != nil { 56 | return err 57 | } 58 | 59 | done := make(chan struct{}, 0) 60 | go func() { 61 | r := bufio.NewReader(pr) 62 | for { 63 | line, _, err := r.ReadLine() 64 | if err != nil { 65 | if err == io.EOF { 66 | log.Info.Println("ffmpeg: process stopped") 67 | } else { 68 | log.Info.Println("ffmpeg:", err) 69 | } 70 | return 71 | } 72 | log.Debug.Println(string(line)) 73 | if strings.Contains(string(line), "Press [q] to stop, [?] for help") { 74 | log.Debug.Println("ffmpeg is now running") 75 | done <- struct{}{} 76 | } 77 | } 78 | }() 79 | 80 | select { 81 | case <-done: 82 | log.Debug.Println("Loopback started") 83 | l.cmd = cmd 84 | return nil 85 | case <-time.After(20 * time.Second): 86 | err := errors.New("Loopback failed to start") 87 | log.Debug.Println(err) 88 | cmd.Process.Signal(syscall.SIGINT) 89 | cmd.Wait() 90 | return err 91 | } 92 | } 93 | 94 | return nil 95 | } 96 | 97 | // Stop stops the loopback. 98 | func (l *loopback) Stop() { 99 | l.mutex.Lock() 100 | defer l.mutex.Unlock() 101 | 102 | if l.cmd != nil { 103 | log.Debug.Println("Stopping loopback") 104 | l.cmd.Process.Signal(syscall.SIGINT) 105 | l.cmd.Wait() 106 | l.cmd = nil 107 | } 108 | } 109 | 110 | // cmd returns a new command to stream video from the input file to the loopback file. 111 | func (l *loopback) execCmd() *exec.Cmd { 112 | cmd := exec.Command("ffmpeg", "-f", l.inputDevice, "-i", l.inputFilename, "-codec:v", "copy", "-f", l.inputDevice, l.loopbackFilename) 113 | 114 | log.Debug.Println(cmd) 115 | 116 | return cmd 117 | } 118 | -------------------------------------------------------------------------------- /ffmpeg/snapshot.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | _ "image/jpeg" 7 | "os" 8 | "os/exec" 9 | "path" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | type Snapshot struct { 15 | Image image.Image 16 | Date time.Time 17 | } 18 | 19 | // snapshot returns an image by grapping a frame of the video stream. 20 | func snapshot(width, height uint, inputDevice, inputFilename string) (*Snapshot, error) { 21 | fileName := fmt.Sprintf("snapshot_%s.jpeg", time.Now().Format(time.RFC3339)) 22 | filePath := path.Join(os.TempDir(), fileName) 23 | 24 | // height "-2" keeps the aspect ratio 25 | arg := fmt.Sprintf("-f %s -framerate 30 -i %s -vf scale=%d:-2 -frames:v 1 %s", inputDevice, inputFilename, width, filePath) 26 | args := strings.Split(arg, " ") 27 | 28 | cmd := exec.Command("ffmpeg", args[:]...) 29 | cmd.Stdout = Stdout 30 | cmd.Stderr = Stderr 31 | 32 | if err := cmd.Run(); err != nil { 33 | return nil, err 34 | } 35 | 36 | img, err := loadImage(filePath) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return &Snapshot{*img, time.Now()}, nil 42 | } 43 | 44 | func loadImage(path string) (*image.Image, error) { 45 | reader, _ := os.Open(path) 46 | defer reader.Close() 47 | img, _, err := image.Decode(reader) 48 | return &img, err 49 | } 50 | -------------------------------------------------------------------------------- /ffmpeg/stream.go: -------------------------------------------------------------------------------- 1 | package ffmpeg 2 | 3 | import ( 4 | "fmt" 5 | "github.com/brutella/hap/log" 6 | "github.com/brutella/hap/rtp" 7 | "os/exec" 8 | "strings" 9 | "syscall" 10 | ) 11 | 12 | type stream struct { 13 | inputDevice string 14 | inputFilename string 15 | h264Decoder string 16 | h264Encoder string 17 | minVideoBitrate int 18 | 19 | req rtp.SetupEndpoints 20 | resp rtp.SetupEndpointsResponse 21 | 22 | cmd *exec.Cmd 23 | } 24 | 25 | func (s *stream) isActive() bool { 26 | return s.cmd != nil 27 | } 28 | 29 | func (s *stream) stop() { 30 | log.Debug.Println("stop stream") 31 | 32 | if s.cmd != nil { 33 | s.cmd.Process.Signal(syscall.SIGINT) 34 | s.cmd.Wait() 35 | s.cmd = nil 36 | } 37 | } 38 | 39 | func (s *stream) start(video rtp.VideoParameters, audio rtp.AudioParameters) error { 40 | log.Debug.Println("start stream") 41 | 42 | // -vsync 2: Fixes "Frame rate very high for a muxer not efficiently supporting it." 43 | // -framerate before -i specifies the framerate for the input, after -i sets it for the output https://stackoverflow.com/questions/38498599/webcam-with-ffmpeg-on-mac-selected-framerate-29-970030-is-not-supported-by-th#38549528 44 | 45 | ffmpegVideo := fmt.Sprintf("-f %s", s.inputDevice) + 46 | fmt.Sprintf(" -framerate %d", s.framerate(video.Attributes)) + 47 | fmt.Sprintf("%s", s.videoDecoderOption(video)) + 48 | fmt.Sprintf(" -re -i %s", s.inputFilename) + 49 | " -an" + 50 | fmt.Sprintf(" -codec:v %s", s.videoEncoder(video)) + 51 | " -pix_fmt yuv420p -vsync vfr" + 52 | 53 | // height "-2" keeps the aspect ratio 54 | fmt.Sprintf(" -video_size %d:-2", video.Attributes.Width) + 55 | fmt.Sprintf(" -framerate %d", video.Attributes.Framerate) + 56 | 57 | // 2019-06-20 (mah) 58 | // Specifying profiles in h264_omx was added in ffmpeg 3.3 59 | // https://github.com/FFmpeg/FFmpeg/commit/13332504c98918447159da2a1a34e377dca360e2#diff-36301d4a4bc7200caee9fbe8e8d8cc20 60 | // hkcam currently uses ffmpeg 3.2 61 | // 2018-08-18 (mah) 62 | // Disable profile arguments because it cannot be parsed 63 | // [h264_omx @ 0x93a410] [Eval @ 0xbeaad160] Undefined constant or missing '(' in 'high' 64 | // fmt.Sprintf(" -profile:v %s", videoProfile(video.CodecParams)) + 65 | fmt.Sprintf(" -level:v %s", videoLevel(video.CodecParams)) + 66 | " -f rawvideo" + 67 | fmt.Sprintf(" -b:v %dk", s.videoBitrate(video)) + 68 | fmt.Sprintf(" -payload_type %d", video.RTP.PayloadType) + 69 | fmt.Sprintf(" -ssrc %d", s.resp.SsrcVideo) + 70 | " -f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80" + 71 | fmt.Sprintf(" -srtp_out_params %s", s.req.Video.SrtpKey()) + 72 | fmt.Sprintf(" srtp://%s:%d?rtcpport=%d&pkt_size=%s&timeout=60", s.req.ControllerAddr.IPAddr, s.req.ControllerAddr.VideoRtpPort, s.req.ControllerAddr.VideoRtpPort, videoMTU(s.req)) 73 | 74 | // FIXME (mah) Audio doesn't work yet 75 | // ffmpegAudio := "-vn" + 76 | // fmt.Sprintf(" %s", audioCodecOption(audio)) + 77 | // // compression-level 0-10 (fastest-slowest) 78 | // fmt.Sprintf(" -b:a %dk -bufsize 48k", audio.RTP.Bitrate) + 79 | // fmt.Sprintf(" -ar %s", audioSamplingRate(audio)) + 80 | // fmt.Sprintf(" -payload_type %d", audio.RTP.PayloadType) + 81 | // fmt.Sprintf(" -ssrc %d", s.resp.SsrcAudio) + 82 | // " -f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80" + 83 | // fmt.Sprintf(" -srtp_out_params %s", s.req.Audio.SrtpKey()) + 84 | // fmt.Sprintf(" srtp://%s:%d?rtcpport=%d&localrtcpport=%d&timeout=60", s.req.ControllerAddr.IPAddr, s.req.ControllerAddr.AudioRtpPort, s.req.ControllerAddr.AudioRtpPort, s.req.ControllerAddr.AudioRtpPort) 85 | 86 | args := strings.Split(ffmpegVideo, " ") 87 | cmd := exec.Command("ffmpeg", args[:]...) 88 | cmd.Stdout = Stdout 89 | cmd.Stderr = Stderr 90 | 91 | log.Debug.Println(cmd) 92 | 93 | err := cmd.Start() 94 | if err == nil { 95 | s.cmd = cmd 96 | } 97 | 98 | return err 99 | } 100 | 101 | // TODO (mah) test 102 | func (s *stream) suspend() { 103 | log.Debug.Println("suspend stream") 104 | s.cmd.Process.Signal(syscall.SIGSTOP) 105 | } 106 | 107 | // TODO (mah) test 108 | func (s *stream) resume() { 109 | log.Debug.Println("resume stream") 110 | s.cmd.Process.Signal(syscall.SIGCONT) 111 | } 112 | 113 | // TODO (mah) implement 114 | func (s *stream) reconfigure(video rtp.VideoParameters, audio rtp.AudioParameters) error { 115 | if s.cmd != nil { 116 | log.Debug.Printf("reconfigure() is not implemented %+v %+v\n", video, audio) 117 | } 118 | 119 | return nil 120 | } 121 | 122 | func (s *stream) videoEncoder(param rtp.VideoParameters) string { 123 | switch param.CodecType { 124 | case rtp.VideoCodecType_H264: 125 | return s.h264Encoder 126 | } 127 | 128 | return "?" 129 | } 130 | 131 | func (s *stream) videoDecoderOption(param rtp.VideoParameters) string { 132 | switch param.CodecType { 133 | case rtp.VideoCodecType_H264: 134 | if s.h264Decoder != "" { 135 | return fmt.Sprintf(" -codec:v %s", s.h264Decoder) 136 | } 137 | } 138 | 139 | return "" 140 | } 141 | 142 | func (s *stream) videoBitrate(param rtp.VideoParameters) int { 143 | br := int(param.RTP.Bitrate) 144 | if s.minVideoBitrate > br { 145 | br = s.minVideoBitrate 146 | } 147 | 148 | return br 149 | } 150 | 151 | // https://superuser.com/a/564007 152 | func videoProfile(param rtp.VideoCodecParameters) string { 153 | for _, p := range param.Profiles { 154 | switch p.Id { 155 | case rtp.VideoCodecProfileConstrainedBaseline: 156 | return "baseline" 157 | case rtp.VideoCodecProfileMain: 158 | return "main" 159 | case rtp.VideoCodecProfileHigh: 160 | return "high" 161 | default: 162 | break 163 | } 164 | } 165 | 166 | return "" 167 | } 168 | 169 | func (s *stream) framerate(attr rtp.VideoCodecAttributes) byte { 170 | if s.inputDevice == "avfoundation" { 171 | // avfoundation only supports 30 fps on a MacBook Pro (Retina, 15-inch, Late 2013) running macOS 10.12 Sierra 172 | // TODO (mah) test this with other Macs 173 | return 30 174 | } 175 | 176 | return attr.Framerate 177 | } 178 | 179 | // https://superuser.com/a/564007 180 | func videoLevel(param rtp.VideoCodecParameters) string { 181 | for _, l := range param.Levels { 182 | switch l.Level { 183 | case rtp.VideoCodecLevel3_1: 184 | return "3.1" 185 | case rtp.VideoCodecLevel3_2: 186 | return "3.2" 187 | case rtp.VideoCodecLevel4: 188 | return "4.0" 189 | default: 190 | break 191 | } 192 | } 193 | 194 | return "" 195 | } 196 | 197 | func videoMTU(setup rtp.SetupEndpoints) string { 198 | switch setup.ControllerAddr.IPVersion { 199 | case rtp.IPAddrVersionv4: 200 | return "1378" 201 | case rtp.IPAddrVersionv6: 202 | return "1228" 203 | } 204 | 205 | return "1378" 206 | } 207 | 208 | // https://trac.ffmpeg.org/wiki/audio%20types 209 | func audioCodecOption(param rtp.AudioParameters) string { 210 | switch param.CodecType { 211 | case rtp.AudioCodecType_PCMU: 212 | log.Debug.Println("audioCodec(PCMU) not supported") 213 | case rtp.AudioCodecType_PCMA: 214 | log.Debug.Println("audioCodec(PCMA) not supported") 215 | case rtp.AudioCodecType_AAC_ELD: 216 | return "-acodec aac" 217 | // return "-acodec libfdk_aac -aprofile aac_eld" // requires ffmpeg built with --enable-libfdk-aac 218 | case rtp.AudioCodecType_Opus: 219 | // requires ffmpeg built with --enable-libopus 220 | // - macOS: brew reinstall ffmpeg --with-opus 221 | return fmt.Sprintf("-acodec libopus") 222 | case rtp.AudioCodecType_MSBC: 223 | log.Debug.Println("audioCodec(MSBC) not supported") 224 | case rtp.AudioCodecType_AMR: 225 | log.Debug.Println("audioCodec(AMR) not supported") 226 | case rtp.AudioCodecType_ARM_WB: 227 | log.Debug.Println("audioCodec(ARM_WB) not supported") 228 | } 229 | 230 | return "" 231 | } 232 | 233 | func audioVariableBitrate(param rtp.AudioParameters) string { 234 | switch param.CodecParams.Bitrate { 235 | case rtp.AudioCodecBitrateVariable: 236 | return "on" 237 | case rtp.AudioCodecBitrateConstant: 238 | return "off" 239 | default: 240 | log.Info.Println("variableBitrate() undefined bitrate", param.CodecParams.Bitrate) 241 | break 242 | } 243 | 244 | return "?" 245 | } 246 | 247 | func audioSamplingRate(param rtp.AudioParameters) string { 248 | switch param.CodecParams.Samplerate { 249 | case rtp.AudioCodecSampleRate8Khz: 250 | return "8k" 251 | case rtp.AudioCodecSampleRate16Khz: 252 | return "16k" 253 | case rtp.AudioCodecSampleRate24Khz: 254 | return "24k" 255 | default: 256 | log.Info.Println("audioSamplingRate() undefined samplerate", param.CodecParams.Samplerate) 257 | break 258 | } 259 | 260 | return "" 261 | } 262 | -------------------------------------------------------------------------------- /get_asset.go: -------------------------------------------------------------------------------- 1 | package hkcam 2 | 3 | import ( 4 | "github.com/brutella/hap/characteristic" 5 | ) 6 | 7 | const TypeGetAsset = "6A6C39F5-67F0-4BE1-BA9D-E56BD27C9606" 8 | 9 | // GetAsset is used to get the raw data of an asset. 10 | // After writing a valid JSON to this characteristic, 11 | // the characteristic value will be the raw data of the requested asset. 12 | // A valid JSON looks like this. `{"id":"1.jpg","width":320,"height":240}` 13 | type GetAsset struct { 14 | *characteristic.Bytes 15 | } 16 | 17 | func NewGetAsset() *GetAsset { 18 | b := characteristic.NewBytes(TypeGetAsset) 19 | b.Permissions = []string{characteristic.PermissionRead, characteristic.PermissionWrite} 20 | b.SetValue([]byte{}) 21 | 22 | return &GetAsset{b} 23 | } 24 | 25 | type GetAssetRequest struct { 26 | ID string `json:"id"` 27 | Width uint `json:"width"` 28 | Height uint `json:"height"` 29 | } 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/brutella/hkcam 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/blang/semver v3.5.1+incompatible 7 | github.com/brutella/go-github-selfupdate v1.2.4-0.20210219120734-e8c1bf329332 8 | github.com/brutella/hap v0.0.14 9 | github.com/go-chi/chi v1.5.4 10 | github.com/gorilla/schema v1.2.0 11 | github.com/mjibson/esc v0.2.0 12 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 13 | github.com/pkg/errors v0.9.1 // indirect 14 | github.com/radovskyb/watcher v1.0.6 15 | github.com/unrolled/render v1.4.1 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= 2 | github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= 3 | github.com/brutella/dnssd v1.2.2 h1:9T6YPay8kD9+nSiX2AG9YfILAbwUll95sh7HhFShatI= 4 | github.com/brutella/dnssd v1.2.2/go.mod h1:JjeBG7F+bH5iIKQ42H9QH0foPmP0L/nHZZaFMbrD5Wg= 5 | github.com/brutella/go-github-selfupdate v1.2.4-0.20210219120734-e8c1bf329332 h1:LIX0Bm/qCQf2AsRvVVM7NUddSnpTr4elcjVVPSPX3V8= 6 | github.com/brutella/go-github-selfupdate v1.2.4-0.20210219120734-e8c1bf329332/go.mod h1:OzhC7w5dAtEeHIHCx6Mi/cFn85sC/pDI10A345ikfZM= 7 | github.com/brutella/hap v0.0.14 h1:TOp6qK2Xyb2tp8CgtfM5q9S8UI5wwhrOAo2+ca6hUhI= 8 | github.com/brutella/hap v0.0.14/go.mod h1:u4vgSv+V5ZAJKmpJOEsM+Y1a+Ce/XhfRDtTjPZ9bljU= 9 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 12 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 13 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 14 | github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= 15 | github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= 16 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 17 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 18 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 19 | github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= 20 | github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= 21 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 22 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 23 | github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= 24 | github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= 25 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 26 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 27 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= 28 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= 29 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 30 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 31 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 32 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 33 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 34 | github.com/miekg/dns v1.1.46 h1:uzwpxRtSVxtcIZmz/4Uz6/Rn7G11DvsaslXoy5LxQio= 35 | github.com/miekg/dns v1.1.46/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= 36 | github.com/mjibson/esc v0.2.0 h1:k96hdaR9Z+nMcnDwNrOvhdBqtjyMrbVyxLpsRCdP2mA= 37 | github.com/mjibson/esc v0.2.0/go.mod h1:9Hw9gxxfHulMF5OJKCyhYD7PzlSdhzXyaGEBRPH1OPs= 38 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 39 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 40 | github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw= 41 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 42 | github.com/onsi/gomega v1.4.2 h1:3mYCb7aPxS/RU7TI1y4rkEn1oKmPRjNJLNEXgw7MH2I= 43 | github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 44 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 45 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 46 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 47 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 48 | github.com/radovskyb/watcher v1.0.6 h1:8WIQ9UxEYMZjem1OwU7dVH94DXXk9mAIE1i8eqHD+IY= 49 | github.com/radovskyb/watcher v1.0.6/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= 50 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 51 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 52 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 53 | github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= 54 | github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= 55 | github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= 56 | github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE= 57 | github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= 58 | github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 59 | github.com/unrolled/render v1.4.1 h1:VdpMc2YkAOWzbmC/P2yoHhRDXgsaCQHcTJ1KK6SNCA4= 60 | github.com/unrolled/render v1.4.1/go.mod h1:cK4RSTTVdND5j9EYEc0LAMOvdG11JeiKjyjfyZRvV2w= 61 | github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 h1:SVoNK97S6JlaYlHcaC+79tg3JUlQABcc0dH2VQ4Y+9s= 62 | github.com/xiam/to v0.0.0-20200126224905-d60d31e03561/go.mod h1:cqbG7phSzrbdg3aj+Kn63bpVruzwDZi58CpxlZkjwzw= 63 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 64 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 65 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 66 | golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 67 | golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 h1:71vQrMauZZhcTVK6KdYM+rklehEEwb3E+ZhaE5jrPrE= 68 | golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 69 | golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= 70 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 71 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 72 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 73 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 74 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 75 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 76 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 77 | golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 78 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= 79 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 80 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 81 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= 82 | golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 83 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 84 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 85 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 86 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 87 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 88 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 89 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 90 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 91 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 92 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 93 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 94 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 95 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 96 | golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= 99 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 101 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 102 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 103 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 104 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 105 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 106 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 107 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 108 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 109 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 110 | golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2 h1:BonxutuHCTL0rBDnZlKjpGIQFTjyUVTexFOdWkB6Fg0= 111 | golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 112 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 113 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 114 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 115 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 116 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 117 | google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= 118 | google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 119 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 120 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 121 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 122 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 123 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 124 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 125 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 126 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 127 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 128 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 129 | -------------------------------------------------------------------------------- /html/error.go: -------------------------------------------------------------------------------- 1 | package html 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // ErrorPage is an error page. 9 | type ErrorPage struct { 10 | Page 11 | CallbackUrl string 12 | Error string 13 | Reason string 14 | } 15 | 16 | func (p *ErrorPage) UpdateWithRequest(r *http.Request, h *Html) { 17 | p.Page.UpdateWithRequest(r, h) 18 | p.Error = "Error" 19 | p.Title = p.Error 20 | } 21 | 22 | func (h *Html) Error(w http.ResponseWriter, r *http.Request, err error) { 23 | p := ErrorPage{ 24 | Reason: fmt.Sprintf("%s", err), 25 | } 26 | p.UpdateWithRequest(r, h) 27 | h.HTML(w, r, http.StatusOK, "error", "layout", &p) 28 | } 29 | -------------------------------------------------------------------------------- /html/home.go: -------------------------------------------------------------------------------- 1 | package html 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func (h *Html) Home(w http.ResponseWriter, r *http.Request) { 8 | var p Page 9 | p.UpdateWithRequest(r, h) 10 | p.Title = "hkcam" 11 | h.HTML(w, r, http.StatusOK, "home", "layout", &p) 12 | } 13 | -------------------------------------------------------------------------------- /html/html.go: -------------------------------------------------------------------------------- 1 | package html 2 | 3 | import ( 4 | "github.com/brutella/hap" 5 | "github.com/brutella/hkcam/api" 6 | "github.com/brutella/hkcam/app" 7 | "github.com/go-chi/chi" 8 | "github.com/unrolled/render" 9 | 10 | "fmt" 11 | "html/template" 12 | "net/http" 13 | neturl "net/url" 14 | ) 15 | 16 | type Html struct { 17 | Store hap.Store 18 | BuildMode string 19 | Api *api.Api 20 | App *app.App 21 | FileSystem render.FileSystem 22 | Render *render.Render 23 | u *app.Update 24 | } 25 | 26 | func (h *Html) HTML(w http.ResponseWriter, r *http.Request, status int, tmpl string, layout string, binding interface{}) { 27 | opt := render.HTMLOptions{ 28 | Layout: layout, 29 | Funcs: template.FuncMap{ 30 | "T": func(format string, args ...interface{}) string { 31 | return fmt.Sprintf(format, args...) 32 | }, 33 | }, 34 | } 35 | 36 | h.Render.HTML(w, status, tmpl, binding, opt) 37 | } 38 | 39 | func (h *Html) Router() http.Handler { 40 | r := chi.NewRouter() 41 | 42 | r.Get("/", h.Home) 43 | r.Post("/cleanup-update-and-restart", h.CleanupUpdateAndRestart) 44 | // r.Get("/system/log", h.Log) 45 | 46 | r.Post("/update/check", h.CheckForUpdate) 47 | r.Post("/update/install/latest", h.InstallLatestVersion) 48 | r.Post("/update/install", h.InstallUpdate) 49 | 50 | return r 51 | } 52 | 53 | // setURLParam returns a new url which contains val for key in url 54 | func setURLParam(url, key, val string) string { 55 | u, err := neturl.Parse(url) 56 | if err == nil { 57 | vals := u.Query() 58 | vals.Set(key, val) 59 | u.RawQuery = vals.Encode() 60 | return u.String() 61 | } 62 | 63 | return url 64 | } 65 | 66 | // getURLValForKey returns the value for key in r. 67 | func getURLParamForKey(r *http.Request, key string) string { 68 | return r.FormValue(key) 69 | } 70 | 71 | // setURLMsg set a message using the "msg" url value. 72 | func setURLMsg(url, msg string) string { 73 | return setURLParam(url, "msg", msg) 74 | } 75 | 76 | // delURLMsg deletes a message from an url. 77 | func delURLMsg(s string) string { 78 | u, err := neturl.Parse(s) 79 | if err != nil { 80 | return s 81 | } 82 | qu := u.Query() 83 | qu.Del("msg") 84 | u.RawQuery = qu.Encode() 85 | return u.String() 86 | } 87 | 88 | // getURLMsg returns the msg encoded into the request's url, 89 | // or an empty string if no message is present. 90 | func getURLMsg(r *http.Request) string { 91 | return getURLParamForKey(r, "msg") 92 | } 93 | -------------------------------------------------------------------------------- /html/page.go: -------------------------------------------------------------------------------- 1 | package html 2 | 3 | import ( 4 | "github.com/brutella/hkcam/app" 5 | 6 | "html/template" 7 | "net/http" 8 | ) 9 | 10 | // Page is the data shown in an HTML page. 11 | type Page struct { 12 | // Title of the page 13 | Title string 14 | 15 | // Referrer of this page without the query part. 16 | // Use this if you don't want to exclude any messages embed in the url 17 | Referrer string 18 | 19 | // Referer is `http.Request.Referer()` and includes the query part from the url. 20 | Referer string 21 | 22 | // Message is the message shown 23 | Message string 24 | 25 | // Update is a system update. 26 | Update *app.Update 27 | 28 | // App is the app. 29 | App *app.App 30 | 31 | Funcs template.FuncMap 32 | 33 | // DebugMode is true when running in debug mode 34 | DebugMode bool 35 | } 36 | 37 | func (p *Page) UpdateWithRequest(r *http.Request, h *Html) { 38 | p.Referer = r.Referer() 39 | p.Referrer = delURLMsg(p.Referer) 40 | p.Message = getURLMsg(r) 41 | p.Update = h.LatestUpdate() 42 | p.App = h.App 43 | p.DebugMode = h.BuildMode == "debug" 44 | } 45 | -------------------------------------------------------------------------------- /html/tmpl/error.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | {{ if .Error }} 5 |

{{ .Error }}
6 | {{ end }} 7 | {{ if .Reason }} 8 |
{{ .Reason }}
9 | {{ end }} 10 |

11 | 12 | {{ T "Back" }} 13 |
14 |
-------------------------------------------------------------------------------- /html/tmpl/home.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
6 |

{{ template "partial/activity-indicator" }}

7 |

{{ T "Loading..." }}

8 |
9 |
10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /html/tmpl/layout.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ template "partial/head" . }} 4 | 5 | {{ template "partial/header" . }} 6 | {{ template "partial/message" . }} 7 | {{ template "partial/update-alert" . }} 8 | {{ yield }} 9 | {{ template "partial/footer" . }} 10 | {{ template "partial/foot" . }} 11 | 12 | -------------------------------------------------------------------------------- /html/tmpl/min-layout.tmpl: -------------------------------------------------------------------------------- 1 | 2 | {{ template "partial/head" . }} 3 | 4 | {{ yield }} 5 | {{ template "partial/foot" . }} 6 | 7 | -------------------------------------------------------------------------------- /html/tmpl/partial/activity-indicator.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "partial/activity-indicator-sm" }} 2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | {{ end }} 13 | 14 | {{ define "partial/activity-indicator" }} 15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {{ end }} -------------------------------------------------------------------------------- /html/tmpl/partial/debug-alert.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "partial/debug-alert" }} 2 | {{ if .DebugMode }} 3 |
4 | You are running a test version of hkcam. 5 |
6 | {{ end }} 7 | {{ end }} -------------------------------------------------------------------------------- /html/tmpl/partial/foot.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "partial/foot" }} 2 | 4 | {{ end }} -------------------------------------------------------------------------------- /html/tmpl/partial/footer.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "partial/footer" }} 2 | 3 |
4 |
5 |
6 |

7 | © Matthias Hochgatterer 8 | • 9 | Github 10 |

11 |
12 |
13 |
14 | {{ end }} -------------------------------------------------------------------------------- /html/tmpl/partial/head.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "partial/head" }} 2 | 3 | 4 | 5 | 6 | 7 | {{ $v := .App.BuildDate.Unix }} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{ .Title }} 20 | 21 | {{ end }} -------------------------------------------------------------------------------- /html/tmpl/partial/header.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "partial/header" }} 2 | 21 |
22 | 23 | 45 | {{ end }} -------------------------------------------------------------------------------- /html/tmpl/partial/message.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "partial/message" }} 2 | {{ if .Message }} 3 |
4 |
5 |
6 |
{{ .Message }}
7 |
8 |
9 |
10 | 15 | {{ end }} 16 | {{ end }} -------------------------------------------------------------------------------- /html/tmpl/partial/update-alert.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "partial/update-alert" }} 2 | {{ if .Update }} 3 | {{ $version := printf "%s" .Update.Version }} 4 | {{ if .Update.Installing }} 5 | 8 | 17 | {{ else if .Update.Success }} 18 | 24 | {{ else if .Update.Failure }} 25 | 33 | {{ else if .Update.Cancelled }} 34 | 40 | {{ else }} 41 | {{ $versionLink := printf "%s" .Update.URL .Update.Version }} 42 |
43 | 48 |
49 | {{ end }} 50 | {{ end }} 51 | {{ end }} -------------------------------------------------------------------------------- /html/tmpl/restart.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

{{ template "partial/activity-indicator" }}

5 |

{{ T "Restarting..." }}

6 |

{{ T "Notice: The executing file is now terminated. Make sure that the system is automatically restarted." }}

7 | 49 |
50 |
51 |
-------------------------------------------------------------------------------- /html/update.go: -------------------------------------------------------------------------------- 1 | package html 2 | 3 | import ( 4 | "github.com/brutella/go-github-selfupdate/selfupdate" 5 | "github.com/brutella/hkcam/app" 6 | 7 | "encoding/json" 8 | "net/http" 9 | ) 10 | 11 | type UpdatePage struct { 12 | Page 13 | } 14 | 15 | // CheckForUpdate downloads the latest release and stores it as a db.Update in the database. 16 | // The user is then redirected to the previous page. 17 | // If no update was found, a message is shown to the user that no updates are available. 18 | func (h *Html) CheckForUpdate(w http.ResponseWriter, r *http.Request) { 19 | p := UpdatePage{} 20 | p.UpdateWithRequest(r, h) 21 | 22 | selfupdate.EnableLog() 23 | 24 | if u, err := h.App.CheckForUpdate(false); err != nil { 25 | h.Error(w, r, err) 26 | return 27 | } else { 28 | url := p.Referrer 29 | if u == nil { 30 | url = setURLMsg(url, "No Update Available") 31 | } 32 | 33 | h.SaveUpdate(u) 34 | http.Redirect(w, r, url, http.StatusSeeOther) 35 | } 36 | } 37 | 38 | // InstallLatestVersion installs the latest release no matter of the current build version 39 | func (h *Html) InstallLatestVersion(w http.ResponseWriter, r *http.Request) { 40 | p := UpdatePage{} 41 | p.UpdateWithRequest(r, h) 42 | 43 | url := p.Referrer 44 | if u := p.Update; u == nil { 45 | // fetch latest version (including pre-releases) 46 | u, err := h.App.LatestVersion(true) 47 | if err != nil { 48 | url = setURLMsg(url, err.Error()) 49 | } else if u == nil { 50 | url = setURLMsg(url, "No Update Available") 51 | } else { 52 | h.SaveUpdate(u) 53 | go func() { 54 | h.App.InstallUpdate(u) 55 | h.SaveUpdate(u) 56 | }() 57 | } 58 | } 59 | 60 | http.Redirect(w, r, url, http.StatusSeeOther) 61 | } 62 | 63 | // InstallUpdate installs the latest release, if an update is already store din the database. 64 | // If no update is stored in the database or an installation process currently running, this method does nothing. 65 | func (h *Html) InstallUpdate(w http.ResponseWriter, r *http.Request) { 66 | p := UpdatePage{} 67 | p.UpdateWithRequest(r, h) 68 | if u := p.Update; u != nil { 69 | switch u.State { 70 | case app.UpdateStateInstall: 71 | // don't try to install twice 72 | break 73 | default: 74 | go func() { 75 | h.App.InstallUpdate(u) 76 | h.SaveUpdate(u) 77 | }() 78 | } 79 | } 80 | http.Redirect(w, r, r.Referer(), http.StatusSeeOther) 81 | } 82 | 83 | // CleanupUpdateAndRestart deletes the latest update from the database and render the restart page. 84 | // The page then restarts the system by terminating it via an Api call. 85 | func (h *Html) CleanupUpdateAndRestart(w http.ResponseWriter, r *http.Request) { 86 | // delete latest update 87 | u := h.LatestUpdate() 88 | h.DeleteUpdate(u) 89 | 90 | p := Page{} 91 | p.UpdateWithRequest(r, h) 92 | p.Title = "Restart" 93 | h.HTML(w, r, http.StatusOK, "restart", "layout", &p) 94 | } 95 | 96 | func (h *Html) LatestUpdate() *app.Update { 97 | if h.u != nil { 98 | return h.u 99 | } 100 | 101 | b, err := h.Store.Get("update") 102 | if err != nil { 103 | return nil 104 | } 105 | 106 | var u app.Update 107 | if err := json.Unmarshal(b, &u); err != nil { 108 | return nil 109 | } 110 | 111 | h.u = &u 112 | 113 | return &u 114 | } 115 | 116 | func (h *Html) SaveUpdate(update *app.Update) error { 117 | b, err := json.Marshal(&update) 118 | if err != nil { 119 | return err 120 | } 121 | h.u = update 122 | return h.Store.Set("update", b) 123 | } 124 | 125 | func (h *Html) DeleteUpdate(update *app.Update) { 126 | h.u = nil 127 | h.Store.Delete("update") 128 | } 129 | -------------------------------------------------------------------------------- /setup.go: -------------------------------------------------------------------------------- 1 | package hkcam 2 | 3 | import ( 4 | "github.com/brutella/hap/accessory" 5 | "github.com/brutella/hap/characteristic" 6 | "github.com/brutella/hap/log" 7 | "github.com/brutella/hap/rtp" 8 | "github.com/brutella/hap/service" 9 | "github.com/brutella/hap/tlv8" 10 | "github.com/brutella/hkcam/ffmpeg" 11 | 12 | "fmt" 13 | "math/rand" 14 | "net" 15 | "net/http" 16 | "reflect" 17 | "strings" 18 | ) 19 | 20 | // SetupFFMPEGStreaming configures a camera to use ffmpeg to stream video. 21 | // The returned handle can be used to interact with the camera (start, stop, take snapshot…). 22 | func SetupFFMPEGStreaming(cam *accessory.Camera, cfg ffmpeg.Config) ffmpeg.FFMPEG { 23 | ff := ffmpeg.New(cfg) 24 | 25 | setupStreamManagement(cam.StreamManagement1, ff, cfg.MultiStream) 26 | setupStreamManagement(cam.StreamManagement2, ff, cfg.MultiStream) 27 | 28 | return ff 29 | } 30 | 31 | func first(ips []net.IP, filter func(net.IP) bool) net.IP { 32 | for _, ip := range ips { 33 | if filter(ip) == true { 34 | return ip 35 | } 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func setupStreamManagement(m *service.CameraRTPStreamManagement, ff ffmpeg.FFMPEG, multiStream bool) { 42 | setTLV8Payload(m.StreamingStatus.Bytes, rtp.StreamingStatus{rtp.StreamingStatusAvailable}) 43 | setTLV8Payload(m.SupportedRTPConfiguration.Bytes, rtp.NewConfiguration(rtp.CryptoSuite_AES_CM_128_HMAC_SHA1_80)) 44 | setTLV8Payload(m.SupportedVideoStreamConfiguration.Bytes, rtp.DefaultVideoStreamConfiguration()) 45 | setTLV8Payload(m.SupportedAudioStreamConfiguration.Bytes, rtp.DefaultAudioStreamConfiguration()) 46 | 47 | m.SelectedRTPStreamConfiguration.OnValueRemoteUpdate(func(buf []byte) { 48 | var cfg rtp.StreamConfiguration 49 | err := tlv8.Unmarshal(buf, &cfg) 50 | if err != nil { 51 | log.Debug.Fatalf("SelectedRTPStreamConfiguration: Could not unmarshal tlv8 data: %s\n", err) 52 | } 53 | 54 | id := ffmpeg.StreamID(cfg.Command.Identifier) 55 | switch cfg.Command.Type { 56 | case rtp.SessionControlCommandTypeStart: 57 | ff.Start(id, cfg.Video, cfg.Audio) 58 | if !multiStream { 59 | // If only one video stream is supported, set the status to busy. 60 | // This way HomeKit knows that nobody is allowed to connect anymore. 61 | // If multiple streams are supported, the status is always available. 62 | setTLV8Payload(m.StreamingStatus.Bytes, rtp.StreamingStatus{rtp.StreamingStatusBusy}) 63 | } 64 | case rtp.SessionControlCommandTypeSuspend: 65 | ff.Suspend(id) 66 | case rtp.SessionControlCommandTypeResume: 67 | ff.Resume(id) 68 | case rtp.SessionControlCommandTypeReconfigure: 69 | ff.Reconfigure(id, cfg.Video, cfg.Audio) 70 | case rtp.SessionControlCommandTypeEnd: 71 | ff.Stop(id) 72 | setTLV8Payload(m.StreamingStatus.Bytes, rtp.StreamingStatus{rtp.StreamingStatusAvailable}) 73 | default: 74 | log.Debug.Printf("Unknown command type %d", cfg.Command.Type) 75 | } 76 | }) 77 | 78 | m.SetupEndpoints.OnValueUpdate(func(new, old []byte, r *http.Request) { 79 | if r == nil { 80 | return 81 | } 82 | 83 | var req rtp.SetupEndpoints 84 | err := tlv8.Unmarshal(new, &req) 85 | if err != nil { 86 | log.Debug.Fatalf("SetupEndpoints: Could not unmarshal tlv8 data: %s\n", err) 87 | } 88 | 89 | iface, err := ifaceOfRequest(r) 90 | if err != nil { 91 | log.Debug.Println(err) 92 | return 93 | } 94 | ip, err := ipAtInterface(*iface, req.ControllerAddr.IPVersion) 95 | if err != nil { 96 | log.Debug.Println(err) 97 | return 98 | } 99 | 100 | // TODO ssrc is different for every stream 101 | ssrcVideo := rand.Int31() 102 | ssrcAudio := rand.Int31() 103 | 104 | resp := rtp.SetupEndpointsResponse{ 105 | SessionId: req.SessionId, 106 | Status: rtp.SessionStatusSuccess, 107 | AccessoryAddr: rtp.Addr{ 108 | IPVersion: req.ControllerAddr.IPVersion, 109 | IPAddr: ip.String(), 110 | VideoRtpPort: req.ControllerAddr.VideoRtpPort, 111 | AudioRtpPort: req.ControllerAddr.AudioRtpPort, 112 | }, 113 | Video: req.Video, 114 | Audio: req.Audio, 115 | SsrcVideo: ssrcVideo, 116 | SsrcAudio: ssrcAudio, 117 | } 118 | 119 | ff.PrepareNewStream(req, resp) 120 | 121 | // After a write, the characteristic should contain a response 122 | setTLV8Payload(m.SetupEndpoints.Bytes, resp) 123 | }) 124 | } 125 | 126 | // ipAtInterface returns the ip at iface with a specific version. 127 | // version is either `rtp.IPAddrVersionv4` or `rtp.IPAddrVersionv6`. 128 | func ipAtInterface(iface net.Interface, version uint8) (net.IP, error) { 129 | addrs, err := iface.Addrs() 130 | if err != nil { 131 | log.Debug.Println(err) 132 | return nil, err 133 | } 134 | 135 | for _, addr := range addrs { 136 | ip, _, err := net.ParseCIDR(addr.String()) 137 | if err != nil { 138 | log.Debug.Println(err) 139 | continue 140 | } 141 | 142 | switch version { 143 | case rtp.IPAddrVersionv4: 144 | if ip.To4() != nil { 145 | return ip, nil 146 | } 147 | case rtp.IPAddrVersionv6: 148 | if ip.To16() != nil { 149 | return ip, nil 150 | } 151 | default: 152 | break 153 | } 154 | } 155 | 156 | return nil, fmt.Errorf("%s: No ip address found for version %d", iface.Name, version) 157 | } 158 | 159 | // ifaceOfRequest returns the network interface at which the connection was established. 160 | func ifaceOfRequest(r *http.Request) (*net.Interface, error) { 161 | v := r.Context().Value(http.LocalAddrContextKey) 162 | if v == nil { 163 | return nil, fmt.Errorf("no local address in context") 164 | } 165 | 166 | host, _, err := net.SplitHostPort(v.(net.Addr).String()) 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | ip := net.ParseIP(host) 172 | // 2019-06-04 (mah) ip might be nil if `host` contains the network interface name 173 | // I couldn't find any documentation why v6 ip address contains the interface name 174 | if ip == nil { 175 | // get the interface name from the host string 176 | // ex. host = "fe80::e627:bec4:30b9:cb12%wlan0" 177 | comps := strings.Split(host, "%") 178 | if len(comps) == 2 { 179 | name := comps[1] 180 | log.Debug.Printf("querying interface with name %s\n", name) 181 | return net.InterfaceByName(name) 182 | } 183 | 184 | return nil, fmt.Errorf("unable to parse ip %s", host) 185 | } 186 | 187 | ifaces, err := net.Interfaces() 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | for _, iface := range ifaces { 193 | addrs, err := iface.Addrs() 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | for _, addr := range addrs { 199 | addrIP, _, err := net.ParseCIDR(addr.String()) 200 | if err != nil { 201 | return nil, err 202 | } 203 | 204 | if reflect.DeepEqual(addrIP, ip) { 205 | return &iface, nil 206 | } 207 | } 208 | } 209 | 210 | return nil, fmt.Errorf("Could not find interface for connection") 211 | } 212 | 213 | func setTLV8Payload(c *characteristic.Bytes, v interface{}) { 214 | if tlv8, err := tlv8.Marshal(v); err == nil { 215 | c.SetValue(tlv8) 216 | } else { 217 | log.Debug.Fatal(err) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /static/css/ispinner.prefixed.css: -------------------------------------------------------------------------------- 1 | .ispinner { 2 | position: relative; 3 | width: 20px; 4 | height: 20px; } 5 | .ispinner .ispinner-blade { 6 | position: absolute; 7 | top: 6.5px; 8 | left: 8.5px; 9 | width: 2.5px; 10 | height: 6.5px; 11 | background-color: #8e8e93; 12 | border-radius: 1.25px; 13 | -webkit-animation: iSpinnerBlade 1s linear infinite; 14 | animation: iSpinnerBlade 1s linear infinite; 15 | will-change: opacity; } 16 | .ispinner .ispinner-blade:nth-child(1) { 17 | -webkit-transform: rotate(45deg) translateY(-6.5px); 18 | transform: rotate(45deg) translateY(-6.5px); 19 | -webkit-animation-delay: -1.625s; 20 | animation-delay: -1.625s; } 21 | .ispinner .ispinner-blade:nth-child(2) { 22 | -webkit-transform: rotate(90deg) translateY(-6.5px); 23 | transform: rotate(90deg) translateY(-6.5px); 24 | -webkit-animation-delay: -1.5s; 25 | animation-delay: -1.5s; } 26 | .ispinner .ispinner-blade:nth-child(3) { 27 | -webkit-transform: rotate(135deg) translateY(-6.5px); 28 | transform: rotate(135deg) translateY(-6.5px); 29 | -webkit-animation-delay: -1.375s; 30 | animation-delay: -1.375s; } 31 | .ispinner .ispinner-blade:nth-child(4) { 32 | -webkit-transform: rotate(180deg) translateY(-6.5px); 33 | transform: rotate(180deg) translateY(-6.5px); 34 | -webkit-animation-delay: -1.25s; 35 | animation-delay: -1.25s; } 36 | .ispinner .ispinner-blade:nth-child(5) { 37 | -webkit-transform: rotate(225deg) translateY(-6.5px); 38 | transform: rotate(225deg) translateY(-6.5px); 39 | -webkit-animation-delay: -1.125s; 40 | animation-delay: -1.125s; } 41 | .ispinner .ispinner-blade:nth-child(6) { 42 | -webkit-transform: rotate(270deg) translateY(-6.5px); 43 | transform: rotate(270deg) translateY(-6.5px); 44 | -webkit-animation-delay: -1s; 45 | animation-delay: -1s; } 46 | .ispinner .ispinner-blade:nth-child(7) { 47 | -webkit-transform: rotate(315deg) translateY(-6.5px); 48 | transform: rotate(315deg) translateY(-6.5px); 49 | -webkit-animation-delay: -0.875s; 50 | animation-delay: -0.875s; } 51 | .ispinner .ispinner-blade:nth-child(8) { 52 | -webkit-transform: rotate(360deg) translateY(-6.5px); 53 | transform: rotate(360deg) translateY(-6.5px); 54 | -webkit-animation-delay: -0.75s; 55 | animation-delay: -0.75s; } 56 | .ispinner.ispinner-large { 57 | width: 35px; 58 | height: 35px; } 59 | .ispinner.ispinner-large .ispinner-blade { 60 | top: 11.5px; 61 | left: 15px; 62 | width: 5px; 63 | height: 12px; 64 | border-radius: 2.5px; } 65 | .ispinner.ispinner-large .ispinner-blade:nth-child(1) { 66 | -webkit-transform: rotate(45deg) translateY(-11.5px); 67 | transform: rotate(45deg) translateY(-11.5px); } 68 | .ispinner.ispinner-large .ispinner-blade:nth-child(2) { 69 | -webkit-transform: rotate(90deg) translateY(-11.5px); 70 | transform: rotate(90deg) translateY(-11.5px); } 71 | .ispinner.ispinner-large .ispinner-blade:nth-child(3) { 72 | -webkit-transform: rotate(135deg) translateY(-11.5px); 73 | transform: rotate(135deg) translateY(-11.5px); } 74 | .ispinner.ispinner-large .ispinner-blade:nth-child(4) { 75 | -webkit-transform: rotate(180deg) translateY(-11.5px); 76 | transform: rotate(180deg) translateY(-11.5px); } 77 | .ispinner.ispinner-large .ispinner-blade:nth-child(5) { 78 | -webkit-transform: rotate(225deg) translateY(-11.5px); 79 | transform: rotate(225deg) translateY(-11.5px); } 80 | .ispinner.ispinner-large .ispinner-blade:nth-child(6) { 81 | -webkit-transform: rotate(270deg) translateY(-11.5px); 82 | transform: rotate(270deg) translateY(-11.5px); } 83 | .ispinner.ispinner-large .ispinner-blade:nth-child(7) { 84 | -webkit-transform: rotate(315deg) translateY(-11.5px); 85 | transform: rotate(315deg) translateY(-11.5px); } 86 | .ispinner.ispinner-large .ispinner-blade:nth-child(8) { 87 | -webkit-transform: rotate(360deg) translateY(-11.5px); 88 | transform: rotate(360deg) translateY(-11.5px); } 89 | 90 | @-webkit-keyframes iSpinnerBlade { 91 | 0% { 92 | opacity: 0.85; } 93 | 50% { 94 | opacity: 0.25; } 95 | 100% { 96 | opacity: 0.25; } } 97 | 98 | @keyframes iSpinnerBlade { 99 | 0% { 100 | opacity: 0.85; } 101 | 50% { 102 | opacity: 0.25; } 103 | 100% { 104 | opacity: 0.25; } } 105 | -------------------------------------------------------------------------------- /static/js/api.js: -------------------------------------------------------------------------------- 1 | function APIRequest(method, url) { 2 | "use strict"; 3 | var xhttp = new XMLHttpRequest(); 4 | xhttp.open(method, url); 5 | return xhttp; 6 | } -------------------------------------------------------------------------------- /static/js/sprintf.js: -------------------------------------------------------------------------------- 1 | /* global window, exports, define */ 2 | 3 | !function() { 4 | 'use strict' 5 | 6 | var re = { 7 | not_string: /[^s]/, 8 | not_bool: /[^t]/, 9 | not_type: /[^T]/, 10 | not_primitive: /[^v]/, 11 | number: /[diefg]/, 12 | numeric_arg: /[bcdiefguxX]/, 13 | json: /[j]/, 14 | not_json: /[^j]/, 15 | text: /^[^\x25]+/, 16 | modulo: /^\x25{2}/, 17 | placeholder: /^\x25(?:([1-9]\d*)\$|\(([^)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-gijostTuvxX])/, 18 | key: /^([a-z_][a-z_\d]*)/i, 19 | key_access: /^\.([a-z_][a-z_\d]*)/i, 20 | index_access: /^\[(\d+)\]/, 21 | sign: /^[+-]/ 22 | } 23 | 24 | function sprintf(key) { 25 | // `arguments` is not an array, but should be fine for this call 26 | return sprintf_format(sprintf_parse(key), arguments) 27 | } 28 | 29 | function vsprintf(fmt, argv) { 30 | return sprintf.apply(null, [fmt].concat(argv || [])) 31 | } 32 | 33 | function sprintf_format(parse_tree, argv) { 34 | var cursor = 1, tree_length = parse_tree.length, arg, output = '', i, k, ph, pad, pad_character, pad_length, is_positive, sign 35 | for (i = 0; i < tree_length; i++) { 36 | if (typeof parse_tree[i] === 'string') { 37 | output += parse_tree[i] 38 | } 39 | else if (typeof parse_tree[i] === 'object') { 40 | ph = parse_tree[i] // convenience purposes only 41 | if (ph.keys) { // keyword argument 42 | arg = argv[cursor] 43 | for (k = 0; k < ph.keys.length; k++) { 44 | if (arg == undefined) { 45 | throw new Error(sprintf('[sprintf] Cannot access property "%s" of undefined value "%s"', ph.keys[k], ph.keys[k-1])) 46 | } 47 | arg = arg[ph.keys[k]] 48 | } 49 | } 50 | else if (ph.param_no) { // positional argument (explicit) 51 | arg = argv[ph.param_no] 52 | } 53 | else { // positional argument (implicit) 54 | arg = argv[cursor++] 55 | } 56 | 57 | if (re.not_type.test(ph.type) && re.not_primitive.test(ph.type) && arg instanceof Function) { 58 | arg = arg() 59 | } 60 | 61 | if (re.numeric_arg.test(ph.type) && (typeof arg !== 'number' && isNaN(arg))) { 62 | throw new TypeError(sprintf('[sprintf] expecting number but found %T', arg)) 63 | } 64 | 65 | if (re.number.test(ph.type)) { 66 | is_positive = arg >= 0 67 | } 68 | 69 | switch (ph.type) { 70 | case 'b': 71 | arg = parseInt(arg, 10).toString(2) 72 | break 73 | case 'c': 74 | arg = String.fromCharCode(parseInt(arg, 10)) 75 | break 76 | case 'd': 77 | case 'i': 78 | arg = parseInt(arg, 10) 79 | break 80 | case 'j': 81 | arg = JSON.stringify(arg, null, ph.width ? parseInt(ph.width) : 0) 82 | break 83 | case 'e': 84 | arg = ph.precision ? parseFloat(arg).toExponential(ph.precision) : parseFloat(arg).toExponential() 85 | break 86 | case 'f': 87 | arg = ph.precision ? parseFloat(arg).toFixed(ph.precision) : parseFloat(arg) 88 | break 89 | case 'g': 90 | arg = ph.precision ? String(Number(arg.toPrecision(ph.precision))) : parseFloat(arg) 91 | break 92 | case 'o': 93 | arg = (parseInt(arg, 10) >>> 0).toString(8) 94 | break 95 | case 's': 96 | arg = String(arg) 97 | arg = (ph.precision ? arg.substring(0, ph.precision) : arg) 98 | break 99 | case 't': 100 | arg = String(!!arg) 101 | arg = (ph.precision ? arg.substring(0, ph.precision) : arg) 102 | break 103 | case 'T': 104 | arg = Object.prototype.toString.call(arg).slice(8, -1).toLowerCase() 105 | arg = (ph.precision ? arg.substring(0, ph.precision) : arg) 106 | break 107 | case 'u': 108 | arg = parseInt(arg, 10) >>> 0 109 | break 110 | case 'v': 111 | arg = arg.valueOf() 112 | arg = (ph.precision ? arg.substring(0, ph.precision) : arg) 113 | break 114 | case 'x': 115 | arg = (parseInt(arg, 10) >>> 0).toString(16) 116 | break 117 | case 'X': 118 | arg = (parseInt(arg, 10) >>> 0).toString(16).toUpperCase() 119 | break 120 | } 121 | if (re.json.test(ph.type)) { 122 | output += arg 123 | } 124 | else { 125 | if (re.number.test(ph.type) && (!is_positive || ph.sign)) { 126 | sign = is_positive ? '+' : '-' 127 | arg = arg.toString().replace(re.sign, '') 128 | } 129 | else { 130 | sign = '' 131 | } 132 | pad_character = ph.pad_char ? ph.pad_char === '0' ? '0' : ph.pad_char.charAt(1) : ' ' 133 | pad_length = ph.width - (sign + arg).length 134 | pad = ph.width ? (pad_length > 0 ? pad_character.repeat(pad_length) : '') : '' 135 | output += ph.align ? sign + arg + pad : (pad_character === '0' ? sign + pad + arg : pad + sign + arg) 136 | } 137 | } 138 | } 139 | return output 140 | } 141 | 142 | var sprintf_cache = Object.create(null) 143 | 144 | function sprintf_parse(fmt) { 145 | if (sprintf_cache[fmt]) { 146 | return sprintf_cache[fmt] 147 | } 148 | 149 | var _fmt = fmt, match, parse_tree = [], arg_names = 0 150 | while (_fmt) { 151 | if ((match = re.text.exec(_fmt)) !== null) { 152 | parse_tree.push(match[0]) 153 | } 154 | else if ((match = re.modulo.exec(_fmt)) !== null) { 155 | parse_tree.push('%') 156 | } 157 | else if ((match = re.placeholder.exec(_fmt)) !== null) { 158 | if (match[2]) { 159 | arg_names |= 1 160 | var field_list = [], replacement_field = match[2], field_match = [] 161 | if ((field_match = re.key.exec(replacement_field)) !== null) { 162 | field_list.push(field_match[1]) 163 | while ((replacement_field = replacement_field.substring(field_match[0].length)) !== '') { 164 | if ((field_match = re.key_access.exec(replacement_field)) !== null) { 165 | field_list.push(field_match[1]) 166 | } 167 | else if ((field_match = re.index_access.exec(replacement_field)) !== null) { 168 | field_list.push(field_match[1]) 169 | } 170 | else { 171 | throw new SyntaxError('[sprintf] failed to parse named argument key') 172 | } 173 | } 174 | } 175 | else { 176 | throw new SyntaxError('[sprintf] failed to parse named argument key') 177 | } 178 | match[2] = field_list 179 | } 180 | else { 181 | arg_names |= 2 182 | } 183 | if (arg_names === 3) { 184 | throw new Error('[sprintf] mixing positional and named placeholders is not (yet) supported') 185 | } 186 | 187 | parse_tree.push( 188 | { 189 | placeholder: match[0], 190 | param_no: match[1], 191 | keys: match[2], 192 | sign: match[3], 193 | pad_char: match[4], 194 | align: match[5], 195 | width: match[6], 196 | precision: match[7], 197 | type: match[8] 198 | } 199 | ) 200 | } 201 | else { 202 | throw new SyntaxError('[sprintf] unexpected placeholder') 203 | } 204 | _fmt = _fmt.substring(match[0].length) 205 | } 206 | return sprintf_cache[fmt] = parse_tree 207 | } 208 | 209 | /** 210 | * export to either browser or node.js 211 | */ 212 | /* eslint-disable quote-props */ 213 | if (typeof exports !== 'undefined') { 214 | exports['sprintf'] = sprintf 215 | exports['vsprintf'] = vsprintf 216 | } 217 | if (typeof window !== 'undefined') { 218 | window['sprintf'] = sprintf 219 | window['vsprintf'] = vsprintf 220 | 221 | if (typeof define === 'function' && define['amd']) { 222 | define(function() { 223 | return { 224 | 'sprintf': sprintf, 225 | 'vsprintf': vsprintf 226 | } 227 | }) 228 | } 229 | } 230 | /* eslint-enable quote-props */ 231 | }(); // eslint-disable-line 232 | -------------------------------------------------------------------------------- /static/js/util.js: -------------------------------------------------------------------------------- 1 | function containsAll(string, array) { 2 | for (var i=0; i < array.length; i++) { 3 | if (string.indexOf( array[i] ) == -1 ) { 4 | return false; 5 | } 6 | } 7 | return true; 8 | } -------------------------------------------------------------------------------- /take_snapshot.go: -------------------------------------------------------------------------------- 1 | package hkcam 2 | 3 | import ( 4 | "github.com/brutella/hap/characteristic" 5 | ) 6 | 7 | const TypeTakeSnapshot = "E8AEE54F-6E4B-46D8-85B2-FECE188FDB08" 8 | 9 | // TakeSnapshot is used to take a snapshot. 10 | // After writing `true` to this characteristic, 11 | // a snapshot is taken and persisted on disk. 12 | type TakeSnapshot struct { 13 | *characteristic.Bool 14 | } 15 | 16 | func NewTakeSnapshot() *TakeSnapshot { 17 | b := characteristic.NewBool(TypeTakeSnapshot) 18 | b.Description = "Take Snapshot" 19 | b.Permissions = []string{characteristic.PermissionWrite} 20 | 21 | return &TakeSnapshot{b} 22 | } 23 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package hkcam 4 | 5 | import _ "github.com/mjibson/esc" 6 | --------------------------------------------------------------------------------