├── .dockerignore ├── .github └── workflows │ ├── README.md │ └── go.yml ├── .gitignore ├── .gitmodules ├── LICENCE ├── README.md ├── bin ├── create_service.sh └── protogen.sh ├── docker-compose.yml ├── dockerfiles ├── go.dev.dockerfile └── go.prod.dockerfile ├── docs ├── architecture │ └── devices.md └── provisioning │ ├── home-automation.conf │ └── raspberry_pi.md ├── filebeat ├── Dockerfile └── filebeat.yml ├── go.mod ├── go.sum ├── libraries ├── go │ ├── bootstrap │ │ ├── bootstrap.go │ │ ├── mysql.go │ │ ├── redis.go │ │ ├── runner.go │ │ └── testing.go │ ├── config │ │ └── env.go │ ├── database │ │ ├── database.go │ │ └── gorm.go │ ├── device │ │ ├── def │ │ │ ├── firehose.go │ │ │ └── types.go │ │ ├── device.def │ │ └── device.go │ ├── distsync │ │ ├── local.go │ │ ├── lock.go │ │ ├── lock_test.go │ │ └── redis.go │ ├── environment │ │ └── environment.go │ ├── exe │ │ ├── exe.go │ │ ├── result.go │ │ └── ssh.go │ ├── firehose │ │ ├── firehose.go │ │ └── mock.go │ ├── healthz │ │ └── healthz.go │ ├── oops │ │ ├── errors.go │ │ └── fmt.go │ ├── protoparse │ │ ├── README.md │ │ └── parse.go │ ├── ptr │ │ └── ptr.go │ ├── queuer │ │ ├── consumer.go │ │ ├── errors.go │ │ ├── helpers.go │ │ ├── publisher.go │ │ └── streams.go │ ├── router │ │ ├── health.go │ │ ├── middleware.go │ │ ├── ping.go │ │ └── router.go │ ├── slog │ │ ├── event.go │ │ ├── fmt.go │ │ ├── logger.go │ │ ├── severity.go │ │ └── stdout.go │ ├── svcdef │ │ ├── README.md │ │ ├── file_reader.go │ │ ├── helpers.go │ │ ├── lex.go │ │ ├── parse.go │ │ ├── parse_test.go │ │ └── types.go │ ├── taxi │ │ ├── README.md │ │ ├── client.go │ │ ├── marshaling.go │ │ ├── marshaling_test.go │ │ ├── mock_client.go │ │ ├── router.go │ │ ├── rpc.go │ │ └── test_fixture.go │ └── util │ │ ├── assert.go │ │ ├── color.go │ │ ├── color_test.go │ │ ├── slice.go │ │ └── string.go ├── javascript │ ├── bootstrap │ │ └── index.js │ ├── config │ │ └── index.js │ ├── darn │ ├── device │ │ ├── Device.js │ │ ├── index.js │ │ └── store.js │ ├── firehose │ │ └── index.js │ ├── http │ │ ├── index.js │ │ └── utils.js │ ├── package-lock.json │ ├── package.json │ └── router │ │ └── index.js └── python │ └── bootstrap │ ├── __init__.py │ └── templates │ └── index.html ├── logstash ├── Dockerfile ├── logstash.conf └── logstash.yml ├── resources └── manifests │ └── service │ ├── deployment.yaml │ └── service.yaml ├── runbooks ├── deployments.md └── upgrade-go.md ├── scripts └── dmx-flicker │ └── main.go ├── service.controller.hue ├── README.md ├── api │ ├── conversions.js │ ├── hueClient.js │ └── marshaling.js ├── dao │ └── index.js ├── dev.dockerfile ├── domain │ ├── HueLight.js │ ├── colorDecorator.js │ ├── colorTempDecorator.js │ ├── decorateDevice.js │ └── rgbDecorator.js ├── index.js ├── package.json ├── prod.dockerfile └── routes │ ├── device.js │ └── index.js ├── service.controller.infrared ├── dao │ └── index.js ├── dev.dockerfile ├── domain │ └── OnkyoHTR380.js ├── index.js ├── ir │ └── index.js ├── package.json ├── prod.dockerfile └── routes │ └── index.js ├── service.controller.plug ├── api │ └── tpLinkClient.js ├── dao │ └── index.js ├── dev.dockerfile ├── domain │ ├── Hs100.js │ └── Hs110.js ├── index.js ├── package.json ├── prod.dockerfile └── routes │ ├── device.js │ └── index.js ├── services ├── api-gateway │ ├── config.dev.yaml │ ├── config.prod.yaml │ └── prod.dockerfile ├── device-registry │ ├── README.md │ ├── def │ │ ├── client.go │ │ └── types.go │ ├── deviceregistry.def │ ├── main.go │ ├── repository │ │ ├── device.go │ │ └── room.go │ └── routes │ │ ├── device.go │ │ ├── handler.go │ │ ├── room.go │ │ └── router.go ├── dmx │ ├── def │ │ ├── client.go │ │ └── types.go │ ├── dmx.def │ ├── dmx │ │ ├── client.go │ │ ├── mock.go │ │ └── ola.go │ ├── domain │ │ ├── base.go │ │ ├── fixture.go │ │ ├── fixture_test.go │ │ ├── fixtures.json │ │ ├── megapar.go │ │ ├── megapar_test.go │ │ ├── mock_fixture.go │ │ └── universe.go │ ├── main.go │ ├── repository │ │ └── device.go │ └── routes │ │ ├── megapar.go │ │ ├── megapar_test.go │ │ ├── router.go │ │ └── setup_test.go ├── dummy │ ├── README.md │ ├── def │ │ ├── client.go │ │ └── types.go │ ├── dummy.def │ ├── main.go │ └── routes │ │ ├── handler.go │ │ └── router.go ├── event-bus │ ├── config │ │ ├── default.json │ │ ├── development.json │ │ └── production.json │ ├── dev.dockerfile │ ├── index.js │ ├── package.json │ └── prod.dockerfile ├── fake-ola │ ├── main.go │ └── routes │ │ └── handler.go ├── fluentd │ ├── Dockerfile │ ├── entrypoint.sh │ ├── fluent.conf │ └── manifests │ │ └── daemonset.yaml ├── infrared │ ├── def │ │ ├── client.go │ │ └── types.go │ ├── domain │ │ ├── device.go │ │ └── onkyo_htr380.go │ ├── infrared.def │ ├── ir │ │ └── ir.go │ ├── main.go │ ├── repository │ │ ├── device.go │ │ └── load.go │ └── routes │ │ ├── handler.go │ │ └── router.go ├── lirc-proxy │ ├── README.md │ ├── def │ │ ├── client.go │ │ └── types.go │ ├── handler │ │ └── router.go │ ├── lirc_proxy.def │ ├── main.go │ └── routes │ │ ├── handler.go │ │ └── router.go ├── log │ ├── domain │ │ └── event.go │ ├── main.go │ ├── prod.dockerfile │ ├── repository │ │ └── log.go │ ├── routes │ │ ├── handler.go │ │ ├── read.go │ │ └── write.go │ ├── templates │ │ └── index.html │ └── watch │ │ └── watch.go ├── mongo │ └── docker-entrypoint-initdb.d │ │ └── init-dev.js ├── ping │ └── main.go ├── scene │ ├── consumer │ │ ├── scene_set.go │ │ └── scene_set_test.go │ ├── dao │ │ └── dao.go │ ├── def │ │ ├── client.go │ │ ├── firehose.go │ │ └── types.go │ ├── domain │ │ ├── action.go │ │ └── scene.go │ ├── main.go │ ├── prod.dockerfile │ ├── routes │ │ ├── handler.go │ │ ├── router.go │ │ ├── scene_create.go │ │ ├── scene_delete.go │ │ ├── scene_list.go │ │ ├── scene_read.go │ │ └── scene_set.go │ ├── scene.def │ └── schema │ │ ├── mock_data.sql │ │ └── schema.sql ├── schedule │ ├── domain │ │ ├── action.go │ │ ├── actor.go │ │ ├── rule.go │ │ └── schedule.go │ └── main.go └── user │ ├── def │ ├── client.go │ └── types.go │ ├── main.go │ ├── prod.dockerfile │ ├── routes │ ├── handler.go │ ├── router.go │ ├── user_get.go │ └── user_list.go │ ├── schema │ ├── mock_data.sql │ └── schema.sql │ └── user.def ├── tools ├── bolt │ ├── README.md │ ├── cmd │ │ ├── build.go │ │ ├── db.go │ │ ├── db_admin.go │ │ ├── db_schema.go │ │ ├── db_seed.go │ │ ├── firehose.go │ │ ├── firehose_publish.go │ │ ├── logs.go │ │ ├── restart.go │ │ ├── root.go │ │ ├── run.go │ │ └── stop.go │ ├── config.json │ ├── dockerfiles │ │ └── go.dockerfile.tmpl │ ├── main.go │ └── pkg │ │ ├── compose │ │ ├── compose.go │ │ └── file.go │ │ ├── config │ │ └── config.go │ │ ├── database │ │ └── database.go │ │ ├── docker │ │ └── docker.go │ │ └── service │ │ └── service.go ├── deploy │ ├── cmd │ │ └── root.go │ ├── main.go │ └── pkg │ │ ├── build │ │ ├── build.go │ │ ├── docker.go │ │ ├── docker_test.go │ │ └── go.go │ │ ├── config │ │ ├── config.go │ │ └── parse.go │ │ ├── deployer │ │ ├── deploy.go │ │ ├── kubernetes │ │ │ └── kubernetes.go │ │ └── systemd │ │ │ ├── helpers.go │ │ │ └── systemd.go │ │ ├── git │ │ └── git.go │ │ ├── output │ │ └── output.go │ │ └── utils │ │ ├── confirm.go │ │ └── scp.go ├── devicegen │ ├── generate.go │ ├── main.go │ └── template_properties.go ├── hooks │ ├── pre-commit │ └── pre-commit.d │ │ └── go-static-analysis ├── install ├── jrpc │ ├── README.md │ ├── generate.go │ ├── helpers.go │ ├── main.go │ ├── template_client.go │ ├── template_firehose.go │ ├── template_router.go │ ├── template_types.go │ └── types.go ├── libraries │ ├── cache │ │ └── cache.go │ ├── env │ │ └── env.go │ └── imports │ │ ├── manager.go │ │ └── resolver.go ├── lint │ ├── .eslintrc │ ├── go.dockerfile │ ├── go_fmt.sh │ ├── js.dockerfile │ ├── js_fmt.sh │ └── lint.sh └── templates │ └── service │ ├── README.md.tmpl │ ├── main.go.tmpl │ ├── routes │ └── handler.go.tmpl │ └── {{service_name_snake}}.def.tmpl └── web.client ├── .env ├── .env.development ├── .eslintignore ├── README.md ├── aliases.config.js ├── darn ├── dev.dockerfile ├── nginx.conf ├── package-lock.json ├── package.json ├── prod.dockerfile ├── public ├── apple-touch-180x180.png ├── favicon.ico └── index.html ├── src ├── App.vue ├── api │ ├── EventConsumer.js │ ├── index.js │ └── utils.js ├── components │ ├── base │ │ ├── ColorCircle.vue │ │ └── Tile.vue │ ├── devices │ │ ├── Device.vue │ │ └── controls │ │ │ ├── NumberControl.vue │ │ │ ├── RgbControl.vue │ │ │ ├── SelectControl.vue │ │ │ ├── SliderControl.vue │ │ │ └── ToggleControl.vue │ ├── errors │ │ ├── BaseError.vue │ │ └── ErrorList.vue │ ├── layouts │ │ ├── BaseLayout.vue │ │ └── SideColumn.vue │ └── pages │ │ ├── ColorPicker.vue │ │ ├── RawJsonViewer.vue │ │ └── RoomSelector.vue ├── design │ ├── base │ │ ├── buttons.scss │ │ ├── grid.scss │ │ ├── input.scss │ │ ├── links.scss │ │ └── list.scss │ ├── index.scss │ ├── pages │ │ └── home.scss │ └── variables │ │ ├── breakpoints.scss │ │ └── index.scss ├── domain │ ├── Device.js │ ├── DeviceHeader.js │ ├── Room.js │ └── marshalling.js ├── main.ts ├── pages │ ├── 404 │ │ └── index.vue │ ├── home │ │ ├── RoomSelector.vue │ │ └── index.vue │ └── room │ │ ├── LightTile.vue │ │ ├── Lights.vue │ │ └── index.vue ├── router │ ├── index.js │ └── routes.js ├── shims-vue.d.ts ├── store │ ├── index.js │ └── modules │ │ ├── devices.js │ │ ├── errors.js │ │ └── rooms.js ├── templates │ └── Base.vue └── utils │ └── validators.js ├── tsconfig.json └── vue.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/vendor 3 | .dockerignore 4 | .git 5 | .github 6 | .vscode 7 | bin 8 | data 9 | dockerfiles 10 | docs 11 | log 12 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions 2 | 3 | These are the workflows that run on each commit to test the correctness and integrity of the new code. 4 | 5 | To test locally, use [act](https://github.com/nektos/act). 6 | 7 | The standard image that `act` uses does not contain the tools needed to run the workflows, but the more complete node images seem to work ok. For example, to run the `lint` job from `go.yml`: 8 | 9 | ```shell 10 | act -j lint -P ubuntu-latest=node:12.16-buster 11 | ``` 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | *.db 3 | .npmrc 4 | dist/ 5 | node_modules/ 6 | vendor/ 7 | service.registry.device/devices.json 8 | /log/ 9 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "private"] 2 | path = private 3 | url = git@github.com:jakewright/home-automation-private.git 4 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jake Wright 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/create_service.sh: -------------------------------------------------------------------------------- 1 | # Usage: bin/create_service.sh foo-service 2 | 3 | set -e 4 | 5 | mkdir "./services/${1}" 6 | 7 | # -n Do not overwrite an existing file 8 | # -R Recurse into directory. Source must end in slash 9 | # to copy the contents of the directory 10 | cp -nR ./tools/templates/service/ "./services/${1}/" 11 | 12 | # https://stackoverflow.com/a/50224830 13 | service_name_pascal=$(echo "${1}" | perl -nE 'say join "", map {ucfirst lc} split /[^[:alnum:]]+/') 14 | service_name_snake=$(echo "${1}" | tr '-' '_') 15 | 16 | # Loop through all the newly copied files & directories 17 | files=$(find "./services/${1}") 18 | for f in $files; do 19 | # Replace any instances of {{service_name_snake}} in the file (or directory) names 20 | new_name=$(echo $f | sed "s/{{service_name_snake}}/${service_name_snake}/") 21 | 22 | # Remove the .tmpl extensions from everything 23 | # https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html#Shell-Parameter-Expansion 24 | new_name=${new_name%.tmpl} 25 | 26 | if [ "$f" != "$new_name" ]; then 27 | mv -n "$f" "$new_name" 28 | fi 29 | done 30 | 31 | # Loop through all of the files only and replace strings inside 32 | files=$(find "./services/${1}" -type f) 33 | for f in $files; do 34 | sed -i '' "s/{{service_name_kebab}}/${1}/" "$f" 35 | sed -i '' "s/{{service_name_pascal}}/${service_name_pascal}/" "$f" 36 | sed -i '' "s/{{service_name_snake}}/${service_name_snake}/" "$f" 37 | done 38 | 39 | go generate "./services/${1}/..." 40 | -------------------------------------------------------------------------------- /dockerfiles/go.dev.dockerfile: -------------------------------------------------------------------------------- 1 | # This is a generic Dockerfile used for running golang services locally. 2 | # It's referenced in the project's Docker Compose file. 3 | 4 | FROM golang:1.15-alpine 5 | 6 | # Alpine doesn't have git but go get needs it 7 | RUN apk add --no-cache git 8 | 9 | # Use a fork of compile-daemon that supports watching multiple directories 10 | RUN go get github.com/jakewright/compile-daemon 11 | 12 | EXPOSE 80 13 | 14 | WORKDIR /app 15 | COPY . . 16 | 17 | RUN go mod download 18 | 19 | # In order for a build argument to be available in the CMD, we must make it an 20 | # environment variable. This is because the CMD is only executed at runtime. The 21 | # ARG command must be after FROM to be available at this point in the Dockerfile. 22 | ARG service_name 23 | ENV SERVICE ${service_name} 24 | 25 | # Copy assets for this service into /assets in the image. The LICENCE file is 26 | # included because Docker requires at least one file in the COPY command, and 27 | # it is assumed that LICENCE will always exist. Any file could be used. 28 | COPY LICENCE ./private/assets/${service_name}/dev/* /assets/ 29 | 30 | # Must use exec form so that compile-daemon receives signals. The graceful-kill 31 | # option then forwards them to the go binary. The -directories option doesn't 32 | # work with the directories the other way around. It might be because of the dot 33 | # in the service name. 34 | CMD ["sh", "-c", "compile-daemon -build=\"go install ./services/${SERVICE}\" -command=/go/bin/${SERVICE} -directories=libraries/go,services/${SERVICE} -log-prefix=false -log-prefix=false -graceful-kill=true -graceful-timeout=10"] 35 | -------------------------------------------------------------------------------- /dockerfiles/go.prod.dockerfile: -------------------------------------------------------------------------------- 1 | # This is a generic Dockerfile used for running golang services in production. 2 | # It's referenced in the deployment config file. 3 | 4 | FROM golang:1.15-alpine 5 | 6 | WORKDIR /app 7 | COPY . . 8 | 9 | RUN go mod download 10 | 11 | ARG service_name 12 | ARG revision 13 | RUN CGO_ENABLED=0 GOOS=linux go install \ 14 | -ldflags "-X github.com/jakewright/home-automation/libraries/go/bootstrap.Revision=${revision}" \ 15 | ./services/${service_name} 16 | 17 | FROM alpine:latest 18 | 19 | # In order for a build argument to be available in the CMD, we must make it an 20 | # environment variable. This is because the CMD is only executed at runtime. 21 | # The ARG command must be after FROM to be available at this point in the Dockerfile. 22 | ARG service_name 23 | ENV SERVICE ${service_name} 24 | 25 | EXPOSE 80 26 | WORKDIR /root/ 27 | COPY --from=0 /go/bin/${service_name} . 28 | 29 | # Copy assets for this service into /assets in the image. The LICENCE file is 30 | # included because Docker requires at least one file in the COPY command, and 31 | # it is assumed that LICENCE will always exist. Any file could be used. 32 | COPY LICENCE ./private/assets/${service_name}/prod/* /assets/ 33 | 34 | # Use the shell form of CMD so that the environment variable gets executed 35 | CMD ./${SERVICE} 36 | -------------------------------------------------------------------------------- /docs/provisioning/home-automation.conf: -------------------------------------------------------------------------------- 1 | # rsyslog configuration to forward all home automation logs to a central syslog host 2 | # this should be placed in /etc/rsyslog.d/home-automation.conf on any production home automation host. 3 | # The systemd service definition should set the SyslogIdentifier to something that starts with 4 | # "home-automation". This currently happens in tools/deploy. 5 | 6 | :programname, startswith, "home-automation" action(type="omfwd" target="192.168.1.100" port="7514" protocol="tcp"\ 7 | action.resumeRetryCount="100"\ 8 | queue.type="linkedList" queue.size="10000") 9 | -------------------------------------------------------------------------------- /filebeat/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.elastic.co/beats/filebeat:7.0.0 2 | COPY ./filebeat/filebeat.yml /usr/share/filebeat/filebeat.yml 3 | 4 | # The Elastic images use a different user that causes permission issues 5 | # Set to root before changing the permissions of filebeat.yml below 6 | USER root 7 | 8 | # filebeat.yml can only be writable by the owner 9 | # https://www.elastic.co/guide/en/beats/libbeat/current/config-file-permissions.html 10 | RUN chown root:root /usr/share/filebeat/filebeat.yml 11 | RUN chmod go-w /usr/share/filebeat/filebeat.yml 12 | 13 | ENV strict.perms false 14 | -------------------------------------------------------------------------------- /filebeat/filebeat.yml: -------------------------------------------------------------------------------- 1 | filebeat.config: 2 | modules: 3 | path: ${path.config}/modules.d/*.yml 4 | reload.enabled: false 5 | 6 | filebeat.autodiscover: 7 | providers: 8 | - type: docker 9 | hints.enabled: true 10 | 11 | output.logstash: 12 | hosts: ["logstash:5044"] 13 | worker: 1 14 | pipelining: 0 15 | 16 | logging.metrics.enabled: false 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jakewright/home-automation 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/cenkalti/backoff/v3 v3.2.2 // indirect 7 | github.com/containerd/continuity v0.0.0-20200928162600-f2cc35102c2a // indirect 8 | github.com/creack/pty v1.1.11 9 | github.com/danielchatfield/go-randutils v0.0.0-20161222111725-43a1b7eba548 10 | github.com/fsnotify/fsnotify v1.4.9 11 | github.com/go-redis/redis/v8 v8.3.0 12 | github.com/go-sql-driver/mysql v1.4.1 13 | github.com/golang/protobuf v1.4.2 14 | github.com/gorilla/websocket v1.4.1 15 | github.com/jakewright/patch v0.0.1 16 | github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a 17 | github.com/jinzhu/gorm v1.9.10 18 | github.com/jpillora/backoff v1.0.0 19 | github.com/julienschmidt/httprouter v1.2.0 20 | github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 21 | github.com/manifoldco/promptui v0.7.0 22 | github.com/mitchellh/mapstructure v1.1.2 23 | github.com/ory/dockertest/v3 v3.6.2 24 | github.com/pkg/errors v0.9.1 25 | github.com/sirupsen/logrus v1.7.0 // indirect 26 | github.com/spf13/cobra v0.0.5 27 | github.com/stretchr/testify v1.6.1 28 | github.com/vrischmann/envconfig v1.2.0 29 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 30 | golang.org/x/mod v0.2.0 31 | golang.org/x/net v0.0.0-20201024042810-be3efd7ff127 // indirect 32 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e 33 | golang.org/x/sys v0.0.0-20201110211018-35f3e6cf4a65 // indirect 34 | golang.org/x/tools v0.0.0-20200117220505-0cba7a3a9ee9 35 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 36 | gopkg.in/yaml.v2 v2.3.0 37 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c 38 | gotest.tools v2.2.0+incompatible 39 | ) 40 | -------------------------------------------------------------------------------- /libraries/go/bootstrap/redis.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v8" 9 | 10 | "github.com/jakewright/home-automation/libraries/go/config" 11 | "github.com/jakewright/home-automation/libraries/go/healthz" 12 | "github.com/jakewright/home-automation/libraries/go/slog" 13 | ) 14 | 15 | // getRedisClient returns a cached instance of a redis.Client. 16 | // If it is being called for the first time, a new connection to 17 | // Redis is initiated. Connection options are read from config. 18 | func (s *Service) getRedisClient() (*redis.Client, error) { 19 | if s.redisClient == nil { 20 | conf := struct { 21 | RedisHost string 22 | RedisPort int 23 | }{} 24 | config.Load(&conf) 25 | 26 | addr := fmt.Sprintf("%s:%d", conf.RedisHost, conf.RedisPort) 27 | slog.Infof("Connecting to Redis at address %s", addr) 28 | s.redisClient = redis.NewClient(&redis.Options{ 29 | Addr: addr, 30 | Password: "", 31 | DB: 0, 32 | MaxRetries: 5, 33 | MinRetryBackoff: time.Second, 34 | MaxRetryBackoff: time.Second * 5, 35 | }) 36 | 37 | s.runner.addDeferred(func() error { 38 | err := s.redisClient.Close() 39 | if err != nil { 40 | slog.Errorf("Failed to close Redis connection: %v", err) 41 | } else { 42 | slog.Debugf("Closed Redis connection") 43 | } 44 | return err 45 | }) 46 | 47 | healthCheck := func(ctx context.Context) error { 48 | _, err := s.redisClient.Ping(ctx).Result() 49 | return err 50 | } 51 | 52 | healthz.RegisterCheck("redis", healthCheck) 53 | 54 | if err := healthCheck(context.Background()); err != nil { 55 | return nil, err 56 | } 57 | } 58 | 59 | return s.redisClient, nil 60 | } 61 | -------------------------------------------------------------------------------- /libraries/go/bootstrap/testing.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import "github.com/jakewright/home-automation/libraries/go/distsync" 4 | 5 | // SetupTest should be called in a TestMain() function to 6 | // setup the various global state that code relies on. 7 | func SetupTest() { 8 | distsync.DefaultLocksmith = distsync.NewLocalLocksmith() 9 | } 10 | -------------------------------------------------------------------------------- /libraries/go/config/env.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/vrischmann/envconfig" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/slog" 7 | ) 8 | 9 | // Load populates the given struct with config from the environment 10 | func Load(conf interface{}) { 11 | if err := envconfig.Init(conf); err != nil { 12 | slog.Panicf("Failed to load config from environment: %v", err) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /libraries/go/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | // Database is an interface for accessing the system's persistent data store 4 | type Database interface { 5 | Find(out interface{}, where ...interface{}) error 6 | Create(value interface{}) error 7 | Delete(value interface{}, where ...interface{}) error 8 | } 9 | -------------------------------------------------------------------------------- /libraries/go/database/gorm.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/oops" 7 | ) 8 | 9 | // Gorm is a database accessor that uses a gorm DB instance 10 | type Gorm struct { 11 | db *gorm.DB 12 | } 13 | 14 | var _ Database = (*Gorm)(nil) 15 | 16 | // NewGorm returns a new database using the specifed gorm instance 17 | func NewGorm(db *gorm.DB) *Gorm { 18 | return &Gorm{ 19 | db: db, 20 | } 21 | } 22 | 23 | // Find marshals all matching records into out 24 | func (g *Gorm) Find(out interface{}, where ...interface{}) error { 25 | if err := g.db.Find(out, where...).Error; err != nil { 26 | if gorm.IsRecordNotFoundError(err) { 27 | return oops.WithCode(err, oops.ErrNotFound) 28 | } 29 | 30 | return oops.Wrap(err, oops.ErrInternalService, "failed to execute find") 31 | } 32 | 33 | return nil 34 | } 35 | 36 | // Create adds a new record based on value 37 | func (g *Gorm) Create(value interface{}) error { 38 | if err := g.db.Create(value).Error; err != nil { 39 | return oops.Wrap(err, oops.ErrInternalService, "failed to execute create") 40 | } 41 | return nil 42 | } 43 | 44 | // Delete performs a hard-delete of matching rows 45 | func (g *Gorm) Delete(value interface{}, where ...interface{}) error { 46 | // Unscoped() disables soft delete 47 | if err := g.db.Unscoped().Delete(value, where...).Error; err != nil { 48 | return oops.Wrap(err, oops.ErrInternalService, "failed to execute delete") 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /libraries/go/device/def/firehose.go: -------------------------------------------------------------------------------- 1 | // Code generated by jrpc. DO NOT EDIT. 2 | 3 | package devicedef 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/jakewright/home-automation/libraries/go/firehose" 9 | "github.com/jakewright/home-automation/libraries/go/oops" 10 | ) 11 | 12 | // Publish publishes the event to the Firehose 13 | func (m *DeviceStateChangedEvent) Publish(ctx context.Context, p firehose.Publisher) error { 14 | if err := m.Validate(); err != nil { 15 | return err 16 | } 17 | 18 | return p.Publish(ctx, "device-state-changed", m) 19 | } 20 | 21 | // DeviceStateChangedEventHandler implements the necessary functions to be a Firehose handler 22 | type DeviceStateChangedEventHandler func(*DeviceStateChangedEvent) firehose.Result 23 | 24 | // HandleEvent handles the Firehose event 25 | func (h DeviceStateChangedEventHandler) HandleEvent(ctx context.Context, decode firehose.Decoder) firehose.Result { 26 | var body DeviceStateChangedEvent 27 | if err := decode(&body); err != nil { 28 | return firehose.Discard(oops.WithMessage(err, "failed to unmarshal payload")) 29 | } 30 | return h(&body) 31 | } 32 | -------------------------------------------------------------------------------- /libraries/go/device/device.def: -------------------------------------------------------------------------------- 1 | message Header { 2 | // id is the globally unique identifier for this device 3 | string id (required) 4 | 5 | // name is the friendly name for this device 6 | string name (required) 7 | 8 | // type is the specific device type e.g. HS100 9 | string type (required) 10 | 11 | // kind is the kind of device e.g. plug 12 | string kind (required) 13 | 14 | // controller_name is the name of the controller that manages this device 15 | string controller_name (required) 16 | 17 | // attributes contains arbitrary metadata about the device 18 | map[string]any attributes 19 | 20 | // state_providers is an array of controller names that provide state for this device 21 | []string state_providers 22 | 23 | // room_id is the ID of the room to which this device belongs 24 | string room_id 25 | } 26 | 27 | message Property { 28 | string type (required) 29 | float64 min 30 | float64 max 31 | string interpolation 32 | []Option options 33 | } 34 | 35 | message Command { 36 | map[string]Arg args 37 | } 38 | 39 | message Arg { 40 | bool required (required) 41 | string type (required) 42 | float64 min 43 | float64 max 44 | []Option options 45 | } 46 | 47 | message Option { 48 | string value (required) 49 | string name (required) 50 | } 51 | 52 | message DeviceStateChangedEvent { 53 | event_name = "device-state-changed" 54 | Header header (required) 55 | any state (required) 56 | } 57 | -------------------------------------------------------------------------------- /libraries/go/device/device.go: -------------------------------------------------------------------------------- 1 | package device 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | //go:generate jrpc device.def 8 | 9 | // Useful constants 10 | const ( 11 | InterpolationDiscrete = "discrete" 12 | InterpolationContinuous = "continuous" 13 | TypeBool = "bool" 14 | TypeInt = "int" 15 | TypeString = "string" 16 | TypeRGB = "rgb" 17 | ) 18 | 19 | // LoadProvidedState returns the state from a set of providers 20 | func LoadProvidedState(ctx context.Context, deviceID string, providers []string) (map[string]interface{}, error) { 21 | state := make(map[string]interface{}) 22 | 23 | for _, provider := range providers { 24 | //url := fmt.Sprintf("%s/provide-state?device_id=%s", provider, deviceID) 25 | 26 | _ = provider 27 | 28 | rsp := &struct { 29 | State map[string]interface{} `json:"state"` 30 | }{} 31 | 32 | //if _, err := rpc.Get(ctx, url, rsp); err != nil { 33 | // return nil, err 34 | //} 35 | 36 | // Merge the new map 37 | for k, v := range rsp.State { 38 | state[k] = v 39 | } 40 | } 41 | 42 | return state, nil 43 | } 44 | -------------------------------------------------------------------------------- /libraries/go/distsync/local.go: -------------------------------------------------------------------------------- 1 | package distsync 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/jakewright/home-automation/libraries/go/oops" 8 | ) 9 | 10 | // LocalLocksmith implements process-scoped locking 11 | type LocalLocksmith struct { 12 | locks sync.Map 13 | } 14 | 15 | // NewLocalLocksmith returns an initialised LocalLocksmith 16 | func NewLocalLocksmith() *LocalLocksmith { 17 | return &LocalLocksmith{ 18 | locks: sync.Map{}, 19 | } 20 | } 21 | 22 | // Forge returns a Locker for the resource 23 | func (l *LocalLocksmith) Forge(resource string) (Locker, error) { 24 | i, _ := l.locks.LoadOrStore(resource, &sync.Mutex{}) 25 | mu := i.(*sync.Mutex) 26 | 27 | return &mutexWrapper{mu}, nil 28 | } 29 | 30 | type mutexWrapper struct { 31 | mu *sync.Mutex 32 | } 33 | 34 | // Lock acquires the lock 35 | func (mw *mutexWrapper) Lock(ctx context.Context) error { 36 | if mw == nil { 37 | return oops.InternalService("tried to lock a nil locker") 38 | } 39 | 40 | c := make(chan struct{}) 41 | 42 | go func() { 43 | mw.mu.Lock() 44 | 45 | select { 46 | case c <- struct{}{}: 47 | // This is the normal case 48 | default: 49 | // There is no receiver which means the function 50 | // has already returned because the timeout was 51 | // reached. We don't need this lock anymore. 52 | mw.Unlock() 53 | } 54 | }() 55 | 56 | select { 57 | case <-c: 58 | return nil // Lock acquired 59 | case <-ctx.Done(): 60 | return oops.WithMessage(ctx.Err(), "failed to acquire lock in time") 61 | } 62 | } 63 | 64 | // Unlock releases the lock 65 | func (mw *mutexWrapper) Unlock() { 66 | if mw == nil { 67 | return // probably ok ¯\_(ツ)_/¯ 68 | } 69 | mw.mu.Unlock() 70 | } 71 | -------------------------------------------------------------------------------- /libraries/go/distsync/lock.go: -------------------------------------------------------------------------------- 1 | package distsync 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/jakewright/home-automation/libraries/go/slog" 9 | ) 10 | 11 | const ( 12 | defaultTimeout = time.Second * 10 13 | defaultExpiration = time.Second * 60 14 | ) 15 | 16 | // Locker is a lock on a resource that can be locked and unlocked 17 | type Locker interface { 18 | Lock(ctx context.Context) error 19 | 20 | // Unlock releases the lock. Implementations of this can fail to unlock for 21 | // various reasons, so arguably this function should return an error. In 22 | // practice though, there's nothing useful you can do with the error and 23 | // the lock will expire at some point anyway. 24 | Unlock() 25 | } 26 | 27 | // Locksmith can forge locks 28 | type Locksmith interface { 29 | Forge(string) (Locker, error) 30 | } 31 | 32 | // DefaultLocksmith is a global instance of Locksmith 33 | var DefaultLocksmith Locksmith 34 | 35 | func mustGetDefaultLocksmith() Locksmith { 36 | if DefaultLocksmith == nil { 37 | slog.Panicf("dsync used before default locksmith set") 38 | } 39 | 40 | return DefaultLocksmith 41 | } 42 | 43 | // Lock will forge a lock for the resource and try to acquire the lock 44 | func Lock(ctx context.Context, resource string, args ...interface{}) (Locker, error) { 45 | for _, v := range args { 46 | resource = fmt.Sprintf("%s:%s", resource, v) 47 | } 48 | 49 | locker, err := mustGetDefaultLocksmith().Forge(resource) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | return locker, locker.Lock(ctx) 55 | } 56 | -------------------------------------------------------------------------------- /libraries/go/distsync/lock_test.go: -------------------------------------------------------------------------------- 1 | package distsync 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "gotest.tools/assert" 8 | ) 9 | 10 | func TestLockSynchronous(t *testing.T) { 11 | DefaultLocksmith = NewLocalLocksmith() 12 | locker, err := Lock(context.Background(), "test") 13 | assert.NilError(t, err) 14 | locker.Unlock() 15 | 16 | locker, err = Lock(context.Background(), "test") 17 | assert.NilError(t, err) 18 | locker.Unlock() 19 | } 20 | 21 | func TestLockInterleaved(t *testing.T) { 22 | DefaultLocksmith = NewLocalLocksmith() 23 | locker, err := Lock(context.Background(), "test") 24 | assert.NilError(t, err) 25 | 26 | // Create a context that will immediately timeout 27 | ctx, cancel := context.WithTimeout(context.Background(), 0) 28 | defer cancel() 29 | 30 | locker2, err := Lock(ctx, "test") 31 | assert.Equal(t, nil, locker2) 32 | assert.ErrorContains(t, err, "failed to acquire lock in time") 33 | 34 | locker.Unlock() 35 | 36 | locker3, err := Lock(context.Background(), "test") 37 | assert.NilError(t, err) 38 | locker3.Unlock() 39 | } 40 | -------------------------------------------------------------------------------- /libraries/go/environment/environment.go: -------------------------------------------------------------------------------- 1 | package environment 2 | 3 | import "github.com/jakewright/home-automation/libraries/go/config" 4 | 5 | const envProd = "prod" 6 | 7 | var conf struct { 8 | Environment string `envconfig:"default=prod"` 9 | } 10 | 11 | func init() { 12 | config.Load(&conf) 13 | } 14 | 15 | // IsProd returns whether the current environment is production, based on config. 16 | func IsProd() bool { 17 | return conf.Environment == envProd 18 | } 19 | -------------------------------------------------------------------------------- /libraries/go/exe/result.go: -------------------------------------------------------------------------------- 1 | package exe 2 | 3 | // Result represents the outcome of running a command. 4 | type Result struct { 5 | Err error 6 | Stdout string 7 | Stderr string 8 | } 9 | -------------------------------------------------------------------------------- /libraries/go/firehose/mock.go: -------------------------------------------------------------------------------- 1 | package firehose 2 | 3 | import "context" 4 | 5 | // MockClient can be used by unit tests 6 | type MockClient struct { 7 | } 8 | 9 | // Publish does nothing 10 | func (m MockClient) Publish(context.Context, string, interface{}) error { 11 | return nil 12 | } 13 | 14 | // Subscribe is not implemented 15 | func (m MockClient) Subscribe(string, Handler) { 16 | panic("implement me") 17 | } 18 | -------------------------------------------------------------------------------- /libraries/go/ptr/ptr.go: -------------------------------------------------------------------------------- 1 | package ptr 2 | 3 | // Byte returns a *byte 4 | func Byte(v byte) *byte { return &v } 5 | 6 | // Int returns an *int 7 | func Int(v int) *int { return &v } 8 | 9 | // Int64 returns an *int64 10 | func Int64(v int64) *int64 { return &v } 11 | 12 | // Float64 returns a *float64 13 | func Float64(v float64) *float64 { return &v } 14 | 15 | // Bool returns a *bool 16 | func Bool(v bool) *bool { return &v } 17 | 18 | // String returns a *string 19 | func String(v string) *string { return &v } 20 | -------------------------------------------------------------------------------- /libraries/go/router/middleware.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "net/http" 5 | "runtime/debug" 6 | 7 | "github.com/jakewright/home-automation/libraries/go/oops" 8 | "github.com/jakewright/home-automation/libraries/go/slog" 9 | "github.com/jakewright/home-automation/libraries/go/taxi" 10 | ) 11 | 12 | func panicRecovery(next http.Handler) http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | defer func() { 15 | if v := recover(); v != nil { 16 | stack := debug.Stack() 17 | err := oops.Wrap(v, oops.ErrInternalService, "recovered from panic", map[string]string{ 18 | "stack": string(stack), 19 | }) 20 | if err := taxi.WriteError(w, err); err != nil { 21 | slog.Errorf("Failed to write response: %v", err) 22 | } 23 | 24 | slog.Error(err) 25 | } 26 | }() 27 | 28 | next.ServeHTTP(w, r) 29 | }) 30 | } 31 | 32 | func revision(revision string) taxi.Middleware { 33 | return func(next http.Handler) http.Handler { 34 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 | w.Header().Set("X-Revision", revision) 36 | next.ServeHTTP(w, r) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /libraries/go/router/ping.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/taxi" 7 | ) 8 | 9 | // PingResponse is returned by PingHandler 10 | type PingResponse struct { 11 | Ping string `json:"ping,omitempty"` 12 | } 13 | 14 | // PingHandler returns a simple pong response 15 | func PingHandler(_ context.Context, _ taxi.Decoder) (interface{}, error) { 16 | return &PingResponse{ 17 | Ping: "pong", 18 | }, nil 19 | } 20 | -------------------------------------------------------------------------------- /libraries/go/slog/logger.go: -------------------------------------------------------------------------------- 1 | package slog 2 | 3 | // Logger logs logs 4 | type Logger interface { 5 | Log(*Event) 6 | } 7 | 8 | // DefaultLogger should be used to log all events 9 | var DefaultLogger Logger 10 | 11 | func mustGetDefaultLogger() Logger { 12 | if DefaultLogger == nil { 13 | DefaultLogger = NewStdoutLogger() 14 | } 15 | 16 | return DefaultLogger 17 | } 18 | 19 | // Debugf logs with DEBUG severity 20 | func Debugf(format string, a ...interface{}) { 21 | mustGetDefaultLogger().Log(newEventFromFormat(DebugSeverity, format, a...)) 22 | } 23 | 24 | // Infof logs with INFO severity 25 | func Infof(format string, a ...interface{}) { 26 | mustGetDefaultLogger().Log(newEventFromFormat(InfoSeverity, format, a...)) 27 | } 28 | 29 | // Warnf logs with WARNING severity 30 | func Warnf(format string, a ...interface{}) { 31 | mustGetDefaultLogger().Log(newEventFromFormat(WarnSeverity, format, a...)) 32 | } 33 | 34 | // Errorf logs with ERROR severity 35 | func Errorf(format string, a ...interface{}) { 36 | mustGetDefaultLogger().Log(newEventFromFormat(ErrorSeverity, format, a...)) 37 | } 38 | 39 | // Error logs with ERROR severity 40 | func Error(v interface{}) { 41 | mustGetDefaultLogger().Log(newEvent(ErrorSeverity, v)) 42 | } 43 | 44 | // Panicf logs with ERROR severity and then panics 45 | func Panicf(format string, a ...interface{}) { 46 | Errorf(format, a...) 47 | panic(newEventFromFormat(ErrorSeverity, format, a...)) 48 | } 49 | 50 | // Panic logs with ERROR severity and then panics 51 | func Panic(v interface{}) { 52 | Error(v) 53 | panic(newEvent(ErrorSeverity, v)) 54 | } 55 | -------------------------------------------------------------------------------- /libraries/go/slog/severity.go: -------------------------------------------------------------------------------- 1 | package slog 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | ) 7 | 8 | // Severity is a subset of the syslog severity levels 9 | type Severity int 10 | 11 | const ( 12 | // DebugSeverity is the severity used for debug-level messages 13 | DebugSeverity Severity = 2 14 | 15 | // InfoSeverity is the severity used for informational messages 16 | InfoSeverity Severity = 3 17 | 18 | // WarnSeverity is the severity used for warning conditions 19 | WarnSeverity Severity = 5 20 | 21 | // ErrorSeverity is the severity used for error conditions 22 | ErrorSeverity Severity = 6 23 | 24 | // UnknownSeverity is the value used when the severity cannot be derived 25 | UnknownSeverity Severity = 10 26 | ) 27 | 28 | // String returns the name of the severity level 29 | func (s Severity) String() string { 30 | switch s { 31 | case DebugSeverity: 32 | return "DEBUG" 33 | case InfoSeverity: 34 | return "INFO" 35 | case WarnSeverity: 36 | return "WARN" 37 | case ErrorSeverity: 38 | return "ERROR" 39 | } 40 | 41 | return "UNKNOWN" 42 | } 43 | 44 | // UnmarshalJSON unmarshals a JSON string into a Severity 45 | func (s *Severity) UnmarshalJSON(data []byte) error { 46 | var str string 47 | if err := json.Unmarshal(data, &str); err != nil { 48 | return err 49 | } 50 | 51 | switch strings.ToLower(str) { 52 | case "dbg", "debug": 53 | *s = DebugSeverity 54 | case "inf", "info", "information": 55 | *s = InfoSeverity 56 | case "warn", "warning": 57 | *s = WarnSeverity 58 | case "err", "error": 59 | *s = ErrorSeverity 60 | default: 61 | *s = UnknownSeverity 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /libraries/go/slog/stdout.go: -------------------------------------------------------------------------------- 1 | package slog 2 | 3 | import "fmt" 4 | 5 | // StdoutLogger writes all logs to stdout 6 | type StdoutLogger struct{} 7 | 8 | // NewStdoutLogger returns a StdoutLogger for the service with the given name 9 | func NewStdoutLogger() Logger { 10 | return &StdoutLogger{} 11 | } 12 | 13 | // Log prints the event to stdout 14 | func (l *StdoutLogger) Log(event *Event) { 15 | fmt.Println(event.String()) 16 | } 17 | -------------------------------------------------------------------------------- /libraries/go/svcdef/file_reader.go: -------------------------------------------------------------------------------- 1 | package svcdef 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | ) 7 | 8 | // FileReader is an interface that wraps ReadFile and SeenFile 9 | type FileReader interface { 10 | ReadFile(filename string) ([]byte, error) 11 | SeenFile(filename string) bool 12 | } 13 | 14 | type mockFileReader struct { 15 | files map[string][]byte 16 | seen map[string]bool 17 | } 18 | 19 | // ReadFile returns the bytes of the file with the given name 20 | func (r *mockFileReader) ReadFile(filename string) ([]byte, error) { 21 | if r.seen == nil { 22 | r.seen = map[string]bool{} 23 | } 24 | 25 | r.seen[filename] = true 26 | 27 | if b, ok := r.files[filename]; ok { 28 | return b, nil 29 | } 30 | 31 | return nil, os.ErrNotExist 32 | } 33 | 34 | // SeenFile returns true if the file has already been read 35 | func (r *mockFileReader) SeenFile(filename string) bool { 36 | if r.seen == nil { 37 | r.seen = map[string]bool{} 38 | } 39 | 40 | return r.seen[filename] 41 | } 42 | 43 | type osFileReader struct { 44 | seen map[string]bool 45 | } 46 | 47 | // ReadFile returns the bytes of the file with the given name 48 | func (r *osFileReader) ReadFile(filename string) ([]byte, error) { 49 | if r.seen == nil { 50 | r.seen = map[string]bool{} 51 | } 52 | 53 | r.seen[filename] = true 54 | 55 | return ioutil.ReadFile(filename) 56 | } 57 | 58 | // SeenFile returns true if the file has already been read 59 | func (r *osFileReader) SeenFile(filename string) bool { 60 | if r.seen == nil { 61 | r.seen = map[string]bool{} 62 | } 63 | 64 | return r.seen[filename] 65 | } 66 | -------------------------------------------------------------------------------- /libraries/go/taxi/marshaling_test.go: -------------------------------------------------------------------------------- 1 | package taxi 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "testing" 7 | 8 | "gotest.tools/assert" 9 | ) 10 | 11 | func TestDecodeQuery(t *testing.T) { 12 | r, err := http.NewRequest("GET", "/baz?foo=bar", nil) 13 | assert.NilError(t, err) 14 | 15 | var v struct { 16 | Foo string 17 | } 18 | 19 | err = DecodeRequest(r, &v) 20 | assert.NilError(t, err) 21 | 22 | assert.Equal(t, v.Foo, "bar") 23 | } 24 | 25 | func TestDecodeBody(t *testing.T) { 26 | body := []byte("{\"foo\":\"bar\"}") 27 | r, err := http.NewRequest("POST", "/foo", bytes.NewBuffer(body)) 28 | assert.NilError(t, err) 29 | 30 | var v struct { 31 | Foo string 32 | } 33 | 34 | err = DecodeRequest(r, &v) 35 | assert.NilError(t, err) 36 | 37 | assert.Equal(t, v.Foo, "bar") 38 | } 39 | 40 | func TestDecodeIntoMap(t *testing.T) { 41 | body := []byte("{\"foo\":\"bar\"}") 42 | r, err := http.NewRequest("GET", "/baz?baz=qux", bytes.NewBuffer(body)) 43 | assert.NilError(t, err) 44 | 45 | var v map[string]string 46 | 47 | err = DecodeRequest(r, &v) 48 | assert.NilError(t, err) 49 | 50 | assert.Equal(t, v["foo"], "bar") 51 | assert.Equal(t, v["baz"], "qux") 52 | } 53 | 54 | func TestDecodeComplexParamNames(t *testing.T) { 55 | body := []byte("{\"animal_color\":\"black\"}") 56 | r, err := http.NewRequest("GET", "/foo?house_name=Buckingham%20Palace", bytes.NewBuffer(body)) 57 | assert.NilError(t, err) 58 | 59 | var v struct { 60 | AnimalColor string `json:"animal_color"` 61 | HouseName string `json:"house_name"` 62 | } 63 | 64 | err = DecodeRequest(r, &v) 65 | assert.NilError(t, err) 66 | 67 | assert.Equal(t, v.AnimalColor, "black") 68 | assert.Equal(t, v.HouseName, "Buckingham Palace") 69 | } 70 | -------------------------------------------------------------------------------- /libraries/go/taxi/rpc.go: -------------------------------------------------------------------------------- 1 | package taxi 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | ) 10 | 11 | // RPC represents a remote procedure call 12 | type RPC struct { 13 | Method string 14 | URL string 15 | Body interface{} 16 | } 17 | 18 | // ToRequest converts the RPC into an http.Request 19 | func (r *RPC) ToRequest(ctx context.Context) (*http.Request, error) { 20 | buf := bytes.Buffer{} 21 | if err := json.NewEncoder(&buf).Encode(r.Body); err != nil { 22 | return nil, fmt.Errorf("failed to encode body as JSON: %w", err) 23 | } 24 | 25 | req, err := http.NewRequestWithContext(ctx, r.Method, r.URL, &buf) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to create HTTP request: %w", err) 28 | } 29 | 30 | req.Header.Set("Content-Type", contentTypeJSON) 31 | return req, nil 32 | } 33 | 34 | // RPC returns itself so an RPC implements the 35 | // rpcProvider interface used by the TestFixture 36 | func (r *RPC) RPC() *RPC { 37 | return r 38 | } 39 | -------------------------------------------------------------------------------- /libraries/go/util/assert.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // ExactlyOne returns true iff one of the given booleans is true 4 | func ExactlyOne(bs ...bool) bool { 5 | n := 0 6 | for _, b := range bs { 7 | if b { 8 | n++ 9 | } 10 | } 11 | return n == 1 12 | } 13 | -------------------------------------------------------------------------------- /libraries/go/util/slice.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // UniqueStr removes duplicates from a string slice 4 | func UniqueStr(slice []string) []string { 5 | m := map[string]struct{}{} 6 | for _, s := range slice { 7 | m[s] = struct{}{} 8 | } 9 | 10 | result := make([]string, 0, len(m)) 11 | for s := range m { 12 | result = append(result, s) 13 | } 14 | 15 | return result 16 | } 17 | -------------------------------------------------------------------------------- /libraries/go/util/string.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "strings" 4 | 5 | // RemoveWhitespaceStrings removes any strings from the input slice that 6 | // contain only whitespace and trims whitespace from the remaining lines. 7 | func RemoveWhitespaceStrings(a []string) []string { 8 | result := make([]string, 0, len(a)) 9 | 10 | for _, s := range a { 11 | s = strings.TrimSpace(s) 12 | 13 | if s == "" { 14 | continue 15 | } 16 | 17 | result = append(result, s) 18 | } 19 | 20 | return result 21 | } 22 | -------------------------------------------------------------------------------- /libraries/javascript/bootstrap/index.js: -------------------------------------------------------------------------------- 1 | const req = require("../http"); 2 | const config = require("../config"); 3 | 4 | exports = module.exports = async serviceName => { 5 | // Initialise req for making requests to other services 6 | const apiGateway = process.env.API_GATEWAY; 7 | if (!apiGateway) throw new Error("API_GATEWAY env var not set"); 8 | req.setApiGateway(apiGateway); 9 | 10 | // Load config 11 | const configContents = await req.get(`service.config/read/${serviceName}`); 12 | config.setContents(configContents); 13 | }; 14 | -------------------------------------------------------------------------------- /libraries/javascript/config/index.js: -------------------------------------------------------------------------------- 1 | class Config { 2 | setContents(config) { 3 | this.config = config; 4 | } 5 | 6 | has(path) { 7 | const value = this.get(path); 8 | return value !== undefined; 9 | } 10 | 11 | get(path, def) { 12 | // Throw an error if the config hasn't been loaded 13 | if (this.config === undefined) { 14 | throw new Error("Config not loaded"); 15 | } 16 | 17 | const reduce = (parts, config) => { 18 | // If this is the last part of the key 19 | if (parts.length === 0) { 20 | // Return the value 21 | return config; 22 | } 23 | 24 | // If config is not an object then we can't continue 25 | if (config == null || typeof config !== "object") { 26 | // Return the default 27 | return def; 28 | } 29 | 30 | // Take the first part of the path 31 | const key = parts.shift(); 32 | 33 | // If the key we are searching for is not defined 34 | if (!(key in config)) { 35 | // Return the default 36 | return def; 37 | } 38 | 39 | // Recurse 40 | return reduce(parts, config[key]); 41 | }; 42 | 43 | return reduce(path.split("."), this.config); 44 | } 45 | } 46 | 47 | const config = new Config(); 48 | exports = module.exports = config; 49 | -------------------------------------------------------------------------------- /libraries/javascript/darn: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker \ 4 | run --rm -it \ 5 | --volume "$PWD":/usr/src/app \ 6 | --volume $(dirname "$(dirname "$PWD")")/private/.npmrc:/usr/src/app/.npmrc \ 7 | --workdir /usr/src/app \ 8 | node:12 npm "$@" 9 | -------------------------------------------------------------------------------- /libraries/javascript/device/index.js: -------------------------------------------------------------------------------- 1 | const Device = require("./Device"); 2 | const store = require("./store"); 3 | 4 | exports = module.exports = { Device, store }; 5 | -------------------------------------------------------------------------------- /libraries/javascript/http/utils.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | 3 | /** Recursively convert an object's keys to camel case */ 4 | const toCamelCase = input => { 5 | if (_.isArray(input)) { 6 | return input.map(toCamelCase); 7 | } 8 | 9 | if (!_.isPlainObject(input)) { 10 | return input; 11 | } 12 | 13 | const result = {}; 14 | 15 | _.forEach(input, (value, key) => { 16 | const newKey = _.camelCase(key); 17 | result[newKey] = toCamelCase(value); 18 | }); 19 | 20 | return result; 21 | }; 22 | 23 | /** Recursively convert an object's keys to snake case */ 24 | const toSnakeCase = input => { 25 | if (_.isArray(input)) { 26 | return input.map(toSnakeCase); 27 | } 28 | 29 | if (!_.isPlainObject(input)) { 30 | return input; 31 | } 32 | 33 | const result = {}; 34 | 35 | _.forEach(input, (value, key) => { 36 | const newKey = _.snakeCase(key); 37 | result[newKey] = toSnakeCase(value); 38 | }); 39 | 40 | return result; 41 | }; 42 | 43 | exports = module.exports = { toCamelCase, toSnakeCase }; 44 | -------------------------------------------------------------------------------- /libraries/javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "javascript", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "axios": "^0.18.1", 13 | "body-parser": "^1.18.3", 14 | "config": "^2.0.1", 15 | "express": "^4.16.3", 16 | "lodash": "^4.17.15", 17 | "redis": "^2.8.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libraries/javascript/router/index.js: -------------------------------------------------------------------------------- 1 | const bodyParser = require("body-parser"); 2 | const express = require("express"); 3 | const config = require("../config"); 4 | 5 | class Router { 6 | constructor() { 7 | this.app = express(); 8 | 9 | // JSON body parser 10 | this.app.use(bodyParser.json()); 11 | 12 | // Request logger 13 | this.app.use((req, res, next) => { 14 | console.log( 15 | `${req.method} ${req.originalUrl} ${JSON.stringify(req.body)}` 16 | ); 17 | next(); 18 | }); 19 | } 20 | 21 | listen() { 22 | // Add an error handler that returns valid JSON 23 | this.app.use(function(err, req, res, next) { 24 | console.error(err.stack); 25 | res.status(500); 26 | res.json({ message: err.message }); 27 | }); 28 | 29 | const port = config.get("port", 80); 30 | this.app.listen(port, () => { 31 | console.log(`Service running on port ${port}`); 32 | }); 33 | } 34 | 35 | use(path, handler) { 36 | this.app.use(path, handler); 37 | } 38 | 39 | get(path, handler) { 40 | this.app.get(path, handler); 41 | } 42 | 43 | put(path, handler) { 44 | this.app.put(path, handler); 45 | } 46 | 47 | post(path, handler) { 48 | this.app.post(path, handler); 49 | } 50 | 51 | patch(path, handler) { 52 | this.app.patch(path, handler); 53 | } 54 | } 55 | 56 | const router = new Router(); 57 | exports = module.exports = router; 58 | -------------------------------------------------------------------------------- /libraries/python/bootstrap/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title or 'Home Automation' }} 5 | 36 | 37 | 38 | {{ content }} 39 | 40 | 41 | -------------------------------------------------------------------------------- /logstash/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.elastic.co/logstash/logstash:7.0.0 2 | 3 | COPY ./logstash/logstash.yml /usr/share/logstash/config/logstash.yml 4 | COPY ./logstash/logstash.conf /usr/share/logstash/pipeline/logstash.conf 5 | 6 | # Stop Filebeat from harvesting logs from a container running this image 7 | LABEL "co.elastic.logs/disable"="true" 8 | 9 | # Set the user to root to avoid permissions errors on the mounted log volume 10 | USER root 11 | 12 | # Ports for Filebeat and to receive Syslog messages 13 | EXPOSE 5044 7514 -------------------------------------------------------------------------------- /logstash/logstash.yml: -------------------------------------------------------------------------------- 1 | http.host: 0.0.0.0 2 | config: 3 | reload: 4 | automatic: true 5 | interval: 5s 6 | pipeline: 7 | workers: 1 8 | -------------------------------------------------------------------------------- /resources/manifests/service/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ .ServiceName }} 5 | labels: 6 | app: {{ .ServiceName }} 7 | annotations: 8 | revision: {{ .Revision }} 9 | deployed_at: {{ .DeploymentTimestamp }} 10 | spec: 11 | replicas: 1 12 | selector: 13 | matchLabels: 14 | app: {{ .ServiceName }} 15 | template: 16 | metadata: 17 | labels: 18 | app: {{ .ServiceName }} 19 | spec: 20 | containers: 21 | - name: {{ .ServiceName }} 22 | image: {{ .Image }} 23 | imagePullPolicy: Always 24 | ports: 25 | - containerPort: {{ .ContainerPort }} 26 | env: 27 | {{- range $key, $value := .Config }} 28 | - name: {{ $key }} 29 | value: "{{ $value }}" 30 | {{- end }} 31 | -------------------------------------------------------------------------------- /resources/manifests/service/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ .ServiceName }} 5 | labels: 6 | app: {{ .ServiceName }} 7 | spec: 8 | type: NodePort 9 | ports: 10 | - name: http 11 | port: {{ .ServicePort }} # The port exposed inside the k8s cluster 12 | targetPort: {{ .ContainerPort }} # The port that the pod listens on 13 | {{ if gt .NodePort 0 }}nodePort: {{ .NodePort }}{{ end }} 14 | selector: 15 | app: {{ .ServiceName }} 16 | -------------------------------------------------------------------------------- /runbooks/deployments.md: -------------------------------------------------------------------------------- 1 | # Deployments Runbook 2 | 3 | ### My changes are not being reflected in production after a deployment 4 | 5 | 1. Check that the changes have been committed *and pushed*. The `deploy` tool uses a mirror of the repository, so all changes must exist on the remote. 6 | 2. Did the commit hash actually change? E.g. if you only made changes to the deploy tool itself, or a Dockerfile, and therefore did not need to push anything, the commit hash won't have changed. Helm won't replace the pods in this case because the resulting Docker image will have the same name and tag. 7 | -------------------------------------------------------------------------------- /runbooks/upgrade-go.md: -------------------------------------------------------------------------------- 1 | # Upgrade version of go 2 | 3 | - Update the version in `go.mod` 4 | - Update the version in Dockerfiles 5 | - The Dockerfiles in `dockerfiles/` 6 | - Custom Dockerfiles in service directories 7 | - The version in the GitHub workflows in `.github/workflows/` 8 | - Search for any other references to the old version, e.g. `git grep '1\1.14'`. 9 | -------------------------------------------------------------------------------- /scripts/dmx-flicker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/jakewright/home-automation/services/dmx/dmx" 12 | ) 13 | 14 | var client *dmx.OLAClient 15 | 16 | var r, g, b byte 17 | 18 | func main() { 19 | var err error 20 | client, err = dmx.NewOLAClient("http://ola.local", 9090, 1) 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | defer func() { 26 | update(0) 27 | }() 28 | 29 | sig := make(chan os.Signal, 2) 30 | signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) 31 | 32 | // Change the colour every 20 minutes 33 | go func() { 34 | for { 35 | r, g, b = 0, 255, 0 36 | update(255) 37 | time.Sleep(time.Minute * 5) 38 | r, g, b = 255, 50, 0 39 | update(255) 40 | time.Sleep(time.Minute * 10) 41 | } 42 | }() 43 | 44 | for { 45 | select { 46 | case <-sig: 47 | // Exit when a signal is received 48 | return 49 | default: 50 | // Flicker off then on up to 3 times in a row 51 | // for i := 0; i <= rand.Intn(3); i++ { 52 | // b := rand.Intn(255) 53 | // update(byte(b)) // off 54 | // d := 80 + rand.Intn(200) 55 | // time.Sleep(time.Millisecond * time.Duration(d)) 56 | // update(255) // on 57 | // } 58 | 59 | // Small flickers for a while 60 | // for i := 0; i < 400+rand.Intn(400); i++ { 61 | // b := 230 + rand.Intn(25) 62 | // update(byte(b)) 63 | // d := 50 + rand.Intn(50) 64 | // time.Sleep(time.Millisecond * time.Duration(d)) 65 | // } 66 | } 67 | } 68 | } 69 | 70 | func update(brightness byte) { 71 | if err := client.SetValues(context.TODO(), [512]byte{r, g, b, 0, 0, 0, brightness}); err != nil { 72 | fmt.Printf("error: %v", err) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /service.controller.hue/README.md: -------------------------------------------------------------------------------- 1 | # service.controller.hue 2 | 3 | Controls Philips Hue lights -------------------------------------------------------------------------------- /service.controller.hue/api/hueClient.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const { fromDomain, toDomain } = require("./marshaling"); 3 | 4 | class HueClient { 5 | setHost(host) { 6 | this.host = host; 7 | } 8 | 9 | setUsername(username) { 10 | this.username = username; 11 | } 12 | 13 | getClient() { 14 | if (this.client === undefined) { 15 | this.client = axios.create({ 16 | baseURL: `${this.host}/api/${this.username}` 17 | }); 18 | } 19 | 20 | return this.client; 21 | } 22 | 23 | async request(method, url, data) { 24 | const rsp = await this.getClient().request({ method, url, data }); 25 | if (JSON.stringify(rsp.data).includes("error")) { 26 | throw new Error(`Hue response included errors: 27 | ${method} ${url} 28 | ${JSON.stringify(rsp.data)}`); 29 | } 30 | return rsp; 31 | } 32 | 33 | async fetchAllState() { 34 | const rsp = await this.request("get", "/lights"); 35 | const lights = {}; 36 | 37 | for (const hueId in rsp.data) { 38 | lights[hueId] = toDomain(rsp.data[hueId]); 39 | } 40 | 41 | return lights; 42 | } 43 | 44 | async fetchState(hueId) { 45 | const rsp = await this.request("get", `/lights/${hueId}`); 46 | return toDomain(rsp.data); 47 | } 48 | 49 | async applyState(hueId, state) { 50 | state = fromDomain(state); 51 | await this.request("put", `/lights/${hueId}/state`, state); 52 | return this.fetchState(hueId); 53 | } 54 | } 55 | 56 | const hueClient = new HueClient(); 57 | exports = module.exports = hueClient; 58 | -------------------------------------------------------------------------------- /service.controller.hue/api/marshaling.js: -------------------------------------------------------------------------------- 1 | const conversions = require("./conversions"); 2 | 3 | const fromDomain = domain => { 4 | const api = {}; 5 | 6 | if ("power" in domain) { 7 | api.on = Boolean(domain.power); 8 | } 9 | 10 | if ("brightness" in domain) { 11 | api.bri = domain.brightness; 12 | } 13 | 14 | if ("color" in domain) { 15 | api.hue = domain.color.hue; 16 | api.sat = domain.color.saturation; 17 | } 18 | 19 | if ("colorTemp" in domain) { 20 | // Convert from Kelvin to Mirek (Huejay wants a value between 153 and 500) 21 | api.ct = Math.floor(1000000 / domain.colorTemp); 22 | } 23 | 24 | if ("rgb" in domain) { 25 | api.xy = conversions.rgbHexToXy(domain.rgb); 26 | } 27 | 28 | return api; 29 | }; 30 | 31 | const toDomain = api => { 32 | const domain = {}; 33 | 34 | if (api.state.on !== undefined) { 35 | domain.power = api.state.on; 36 | } 37 | 38 | if (api.state.bri !== undefined) { 39 | domain.brightness = api.state.bri; 40 | } 41 | 42 | if (api.state.hue !== undefined && api.state.saturation !== undefined) { 43 | domain.color = { hue: api.state.hue, saturation: api.state.saturation }; 44 | } 45 | 46 | if (api.state.ct !== undefined) { 47 | domain.colorTemp = Math.ceil(1000000 / api.state.ct); 48 | } 49 | 50 | if (api.state.xy !== undefined) { 51 | domain.rgb = conversions.xyToRgbHex(api.state.xy[0], api.state.xy[1]); 52 | } 53 | 54 | return domain; 55 | }; 56 | 57 | exports = module.exports = { fromDomain, toDomain }; 58 | -------------------------------------------------------------------------------- /service.controller.hue/dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | ENV NODE_ENV=development 3 | 4 | # Install nodemon 5 | RUN npm install -g nodemon 6 | 7 | # Add the libraries 8 | RUN mkdir -p /usr/src/libraries/javascript 9 | COPY ./libraries/javascript /usr/src/libraries/javascript 10 | WORKDIR /usr/src/libraries/javascript 11 | RUN npm install 12 | 13 | # Move one level up so node_modules is not overwritten by a mounted directory 14 | RUN mv node_modules /usr/src/libraries/node_modules 15 | 16 | # Create app directory 17 | RUN mkdir -p /usr/src/app 18 | WORKDIR /usr/src/app 19 | 20 | # Install app dependencies 21 | COPY ./service.controller.hue/package.json . 22 | RUN npm install 23 | 24 | # Move one level up so node_modules is not overwritten by a mounted directory 25 | RUN mv node_modules /usr/src/node_modules 26 | 27 | # Bundle app source 28 | COPY ./service.controller.hue . 29 | 30 | # Expose ports for web access and debugging 31 | EXPOSE 80 9229 32 | 33 | CMD nodemon --inspect=0.0.0.0:9229 --watch . --watch /usr/src/libraries/javascript index.js 34 | -------------------------------------------------------------------------------- /service.controller.hue/domain/colorDecorator.js: -------------------------------------------------------------------------------- 1 | const HUE_MIN = 0; 2 | const HUE_MAX = 65536; 3 | const SAT_MIN = 0; 4 | const SAT_MAX = 254; 5 | 6 | const colorDecorator = { 7 | validate(state) { 8 | if ("color" in state) { 9 | ({ hue, saturation } = state.color); 10 | 11 | if (hue < HUE_MIN || hue > HUE_MAX) return `Invalid hue '${hue}'`; 12 | 13 | if (saturation < SAT_MIN || saturation > SAT_MAX) 14 | return `Invalid saturation '${saturation}'`; 15 | } 16 | }, 17 | 18 | transform(state, t) { 19 | if ("color" in state) { 20 | t.color = state.color; 21 | t.power = true; 22 | } 23 | 24 | return t; 25 | }, 26 | 27 | state: { 28 | color: { type: "color" } 29 | } 30 | }; 31 | 32 | exports = module.exports = colorDecorator; 33 | -------------------------------------------------------------------------------- /service.controller.hue/domain/colorTempDecorator.js: -------------------------------------------------------------------------------- 1 | const CT_MIN = 2000; 2 | const CT_MAX = 6536; 3 | 4 | const colorTempDecorator = { 5 | validate(state) { 6 | if ("colorTemp" in state) { 7 | if (state.colorTemp < CT_MIN || state.colorTemp > CT_MAX) 8 | return `Invalid colour temperature '${state.colorTemp}'`; 9 | } 10 | }, 11 | 12 | transform(state, t) { 13 | if ("colorTemp" in state) { 14 | t.colorTemp = state.colorTemp; 15 | t.power = true; 16 | } 17 | 18 | return t; 19 | }, 20 | 21 | state: { 22 | colorTemp: { 23 | prettyName: "colour temperature", 24 | type: "int", 25 | min: CT_MIN, 26 | max: CT_MAX, 27 | interpolation: "continuous" 28 | } 29 | } 30 | }; 31 | 32 | exports = module.exports = colorTempDecorator; 33 | -------------------------------------------------------------------------------- /service.controller.hue/domain/decorateDevice.js: -------------------------------------------------------------------------------- 1 | const decorateDevice = (device, decorator) => { 2 | if (typeof decorator.validate === "function") { 3 | const validate = device.validate; 4 | device.validate = state => { 5 | const err = validate.call(device, state); 6 | if (err !== undefined) return err; 7 | return decorator.validate.call(device, state); 8 | }; 9 | } 10 | 11 | if (typeof decorator.transform === "function") { 12 | const transform = device.transform; 13 | device.transform = state => { 14 | let t = transform.call(device, state); 15 | return decorator.transform.call(device, state, t); 16 | }; 17 | } 18 | 19 | if (typeof decorator.getCommands === "function") { 20 | const getCommands = device.getCommands; 21 | device.getCommands = () => { 22 | let commands = getCommands.call(device); 23 | return decorator.getCommands.call(device, commands); 24 | }; 25 | } 26 | 27 | if (typeof decorator.state === "object") { 28 | // Deep clone the extra state and merge it with the device's existing state 29 | Object.assign(device.state, JSON.parse(JSON.stringify(decorator.state))); 30 | } 31 | }; 32 | 33 | exports = module.exports = decorateDevice; 34 | -------------------------------------------------------------------------------- /service.controller.hue/domain/rgbDecorator.js: -------------------------------------------------------------------------------- 1 | const rgbDecorator = { 2 | validate(state) { 3 | if ("rgb" in state) { 4 | const ok = /^#[0-9A-F]{6}$/i.test(state.rgb); 5 | if (!ok) return `Invalid hex color '${state.rgb}'`; 6 | } 7 | }, 8 | 9 | transform(state, t) { 10 | if ("rgb" in state) { 11 | t.rgb = state.rgb; 12 | t.power = true; 13 | } 14 | return t; 15 | }, 16 | 17 | state: { 18 | rgb: { type: "rgb" } 19 | } 20 | }; 21 | 22 | exports = module.exports = rgbDecorator; 23 | -------------------------------------------------------------------------------- /service.controller.hue/index.js: -------------------------------------------------------------------------------- 1 | const bootstrap = require("../libraries/javascript/bootstrap"); 2 | const config = require("../libraries/javascript/config"); 3 | const hueClient = require("./api/hueClient"); 4 | const router = require("../libraries/javascript/router"); 5 | const dao = require("./dao"); 6 | require("./routes"); 7 | 8 | bootstrap("service.controller.hue") 9 | .then(() => { 10 | // Get Hue Bridge info 11 | if (!config.has("hueBridge.host") || !config.has("hueBridge.username")) { 12 | throw new Error("Host and username must be set in config"); 13 | } 14 | hueClient.setHost(config.get("hueBridge.host")); 15 | hueClient.setUsername(config.get("hueBridge.username")); 16 | 17 | return dao.fetchAllState(); 18 | }) 19 | .then(() => { 20 | router.listen(); 21 | 22 | // Poll for state changes 23 | if (config.get("polling.enabled", false)) { 24 | dao.watch(config.get("polling.interval", 30000)); 25 | } 26 | }) 27 | .catch(err => { 28 | console.error("Error initialising service", err); 29 | }); 30 | -------------------------------------------------------------------------------- /service.controller.hue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "service.controller.hue", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "author": "Jake Wright", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "^1.14.2" 13 | }, 14 | "dependencies": { 15 | "axios": "^0.18.0", 16 | "lodash": "^4.17.10" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /service.controller.hue/prod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | ENV NODE_ENV=production 3 | 4 | # Add the libraries 5 | RUN mkdir -p /usr/src/libraries/javascript 6 | COPY ./libraries/javascript /usr/src/libraries/javascript 7 | WORKDIR /usr/src/libraries/javascript 8 | RUN npm install 9 | 10 | # Create app directory 11 | RUN mkdir -p /usr/src/app 12 | WORKDIR /usr/src/app 13 | 14 | # Install app dependencies 15 | COPY ./service.controller.hue/package.json . 16 | RUN npm install 17 | 18 | # Bundle app source 19 | COPY ./service.controller.hue . 20 | 21 | CMD [ "npm", "run", "start" ] 22 | -------------------------------------------------------------------------------- /service.controller.hue/routes/device.js: -------------------------------------------------------------------------------- 1 | const dao = require("../dao"); 2 | 3 | /** Middleware to load the device */ 4 | const load = (req, res, next) => { 5 | req.device = dao.findById(req.params.deviceId); 6 | 7 | if (!req.device) { 8 | res.status(404); 9 | res.json({ message: "Device not found" }); 10 | return; 11 | } 12 | 13 | next(); 14 | }; 15 | 16 | /** Retrieve a device's current state */ 17 | const get = (req, res) => { 18 | res.json({ data: req.device }); 19 | }; 20 | 21 | /** Update a device. Only properties that are set will be updated. */ 22 | const update = async (req, res, next) => { 23 | let err = req.device.validate(req.body); 24 | if (err !== undefined) { 25 | res.status(400); 26 | res.json({ message: `Invalid state: ${err}` }); 27 | return; 28 | } 29 | 30 | const state = req.device.transform(req.body); 31 | 32 | try { 33 | await dao.applyState(req.device, state); 34 | res.json({ data: req.device }); 35 | } catch (err) { 36 | next(err); 37 | } 38 | }; 39 | 40 | exports = module.exports = { load, get, update }; 41 | -------------------------------------------------------------------------------- /service.controller.hue/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require("../../libraries/javascript/router"); 2 | const device = require("./device"); 3 | 4 | router.use("/device/:deviceId", device.load); 5 | router.get("/device/:deviceId", device.get); 6 | router.patch("/device/:deviceId", device.update); 7 | -------------------------------------------------------------------------------- /service.controller.infrared/dao/index.js: -------------------------------------------------------------------------------- 1 | const req = require("../../libraries/javascript/http"); 2 | const { store } = require ("../../libraries/javascript/device"); 3 | const OnkyoHTR380 = require("../domain/OnkyoHTR380"); 4 | 5 | const findById = identifier => { 6 | return store.findById(identifier); 7 | }; 8 | 9 | const fetchAllState = async () => { 10 | // Get all devices from the registry and add them to the store 11 | (await req.get("service.device-registry/devices", { 12 | controllerName: "service.controller.infrared" 13 | })) 14 | .map(instantiateDevice) 15 | .map(store.addDevice.bind(store)); 16 | 17 | // This usually emits state change events but none of the 18 | // devices have any state stored locally in this service yet. 19 | // This is where, in the future, we should fetch state from 20 | // state providers. 21 | store.flush(); 22 | }; 23 | 24 | const instantiateDevice = header => { 25 | switch (header.type) { 26 | case "infrared.htr380": 27 | return new OnkyoHTR380(header); 28 | } 29 | 30 | throw new Error(`Unknown device type: ${header.type}`) 31 | }; 32 | 33 | exports = module.exports = { findById, fetchAllState }; -------------------------------------------------------------------------------- /service.controller.infrared/dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | 3 | # Install nodemon 4 | RUN npm install -g nodemon 5 | 6 | # Create a dummy irsend file for the service to shell out to 7 | RUN touch /usr/bin/irsend 8 | RUN chmod +x /usr/bin/irsend 9 | 10 | # Add the libraries 11 | RUN mkdir -p /usr/src/libraries/javascript 12 | COPY ./libraries/javascript /usr/src/libraries/javascript 13 | WORKDIR /usr/src/libraries/javascript 14 | RUN npm install 15 | 16 | # Move one level up so node_modules is not overwritten by a mounted directory 17 | RUN mv node_modules /usr/src/libraries/node_modules 18 | 19 | # Create app directory 20 | RUN mkdir -p /usr/src/app 21 | WORKDIR /usr/src/app 22 | RUN mkdir node_modules 23 | 24 | # Install app dependencies 25 | COPY ./service.controller.infrared/package.json . 26 | RUN npm install 27 | 28 | # Move one level up so node_modules is not overwritten by a mounted directory 29 | RUN mv node_modules /usr/src/node_modules 30 | 31 | # Bundle app source 32 | COPY ./service.controller.infrared . 33 | 34 | # Expose ports for web access and debugging 35 | EXPOSE 80 9229 36 | 37 | CMD nodemon --inspect=0.0.0.0:9229 --watch . --watch /usr/src/libraries/javascript index.js 38 | -------------------------------------------------------------------------------- /service.controller.infrared/index.js: -------------------------------------------------------------------------------- 1 | const bootstrap = require("../libraries/javascript/bootstrap"); 2 | const router = require("../libraries/javascript/router"); 3 | const dao = require("./dao"); 4 | require("./routes"); 5 | 6 | const serviceName = "service.controller.infrared"; 7 | bootstrap(serviceName) 8 | .then(() => { 9 | return dao.fetchAllState() 10 | }) 11 | .then(() => { 12 | router.listen(); 13 | }) 14 | .catch(err => { 15 | console.error("Error initialising service", err) 16 | }); -------------------------------------------------------------------------------- /service.controller.infrared/ir/index.js: -------------------------------------------------------------------------------- 1 | const childProcess = require("child_process"); 2 | 3 | // Create a lock because even though JavaScript is single-threaded, the 4 | // sleep function is asynchronous and we don't want to interleave instructions. 5 | let mutex = false; 6 | 7 | const execute = async (instructions) => { 8 | while (mutex) { 9 | // Sleep asynchronously so other code can execute 10 | await sleep(100); 11 | } 12 | 13 | // JavaScript is single-threaded so we don't need to worry about atomicity 14 | mutex = true; 15 | 16 | for (let i = 0; i < instructions.length; i++) { 17 | if (instructions[i] === "wait") { 18 | await sleep(instructions[++i]); 19 | continue; 20 | } 21 | 22 | send(instructions[i], instructions[++i]); 23 | } 24 | 25 | mutex = false; 26 | }; 27 | 28 | const send = (device, key) => { 29 | try { 30 | childProcess.execSync(`irsend SEND_ONCE ${device} ${key}`); 31 | } catch (err) { 32 | console.error(`Process exited with status '${err.status}'`); 33 | console.error(err.message); 34 | console.error(`stderr: ${err.stderr.toString()}`); 35 | console.error(`stdout: ${err.stdout.toString()}`); 36 | 37 | throw new Error(`Failed to call irsend: ${err.message}`); 38 | } 39 | }; 40 | 41 | const sleep = ms => { 42 | return new Promise(resolve => setTimeout(resolve, ms)); 43 | }; 44 | 45 | exports = module.exports = { execute }; 46 | -------------------------------------------------------------------------------- /service.controller.infrared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "service.controller.infrared", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "author": "Jake Wright", 10 | "license": "MIT", 11 | "dependencies": {} 12 | } 13 | -------------------------------------------------------------------------------- /service.controller.infrared/prod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | ENV NODE_ENV=production 3 | 4 | # Add the libraries 5 | RUN mkdir -p /usr/src/libraries/javascript 6 | COPY ./libraries/javascript /usr/src/libraries/javascript 7 | WORKDIR /usr/src/libraries/javascript 8 | RUN npm install 9 | 10 | # Create app directory 11 | RUN mkdir -p /usr/src/app 12 | WORKDIR /usr/src/app 13 | 14 | # Install app dependencies 15 | COPY ./service.controller.infrared/package.json . 16 | RUN npm install 17 | 18 | # Bundle app source 19 | COPY ./service.controller.infrared . 20 | 21 | CMD [ "npm", "run", "start" ] 22 | -------------------------------------------------------------------------------- /service.controller.infrared/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require("../../libraries/javascript/router"); 2 | const dao = require("../dao"); 3 | const ir = require("../ir"); 4 | 5 | /** Middleware to load the device */ 6 | const load = (req, res, next) => { 7 | req.device = dao.findById(req.params.deviceId); 8 | 9 | if (!req.device) { 10 | res.status(404); 11 | res.json({ message: "Device not found" }); 12 | return; 13 | } 14 | 15 | next(); 16 | }; 17 | 18 | /** Retrieve a device's current state */ 19 | const get = (req, res) => { 20 | res.json({ data: req.device }); 21 | }; 22 | 23 | 24 | /** Update a device. Only properties that are set will be updated. */ 25 | const update = async (req, res, next) => { 26 | let state; 27 | 28 | try { 29 | state = req.device.conform(req.body); 30 | } catch (err) { 31 | res.status(400); 32 | res.json({ message: `Failed to validate state: ${err.message}` }); 33 | return; 34 | } 35 | 36 | try { 37 | await ir.execute(req.device.generateInstructions(state)); 38 | res.json({ data: req.device }); 39 | } catch (err) { 40 | next(err); 41 | } 42 | }; 43 | 44 | router.use("/device/:deviceId", load); 45 | router.get("/device/:deviceId", get); 46 | router.patch("/device/:deviceId", update); -------------------------------------------------------------------------------- /service.controller.plug/api/tpLinkClient.js: -------------------------------------------------------------------------------- 1 | const { Client } = require("tplink-smarthome-api"); 2 | 3 | class TpLinkClient { 4 | constructor() { 5 | this.client = new Client(); 6 | this.plugs = {}; 7 | } 8 | 9 | async getStateByHost(host) { 10 | if (!host) { 11 | throw new Error("Host is not set"); 12 | } 13 | 14 | const info = await this.getPlug(host).getInfo(); 15 | 16 | return { 17 | power: Boolean(info.sysInfo.relay_state), 18 | watts: info.emeter.realtime.power 19 | }; 20 | } 21 | 22 | async applyState(host, state) { 23 | if (!("power" in state)) return; 24 | return this.getPlug(host).setPowerState(state.power); 25 | } 26 | 27 | // Private 28 | getPlug(host) { 29 | if (!(host in this.plugs)) { 30 | this.plugs[host] = this.client.getPlug({ host }); 31 | } 32 | 33 | return this.plugs[host]; 34 | } 35 | } 36 | 37 | const tpLinkClient = new TpLinkClient(); 38 | exports = module.exports = tpLinkClient; 39 | -------------------------------------------------------------------------------- /service.controller.plug/dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | 3 | # Install nodemon 4 | RUN npm install -g nodemon 5 | 6 | # Add the libraries 7 | RUN mkdir -p /usr/src/libraries/javascript 8 | COPY ./libraries/javascript /usr/src/libraries/javascript 9 | WORKDIR /usr/src/libraries/javascript 10 | RUN npm install 11 | 12 | # Move one level up so node_modules is not overwritten by a mounted directory 13 | RUN mv node_modules /usr/src/libraries/node_modules 14 | 15 | # Create app directory 16 | RUN mkdir -p /usr/src/app 17 | WORKDIR /usr/src/app 18 | 19 | # Install app dependencies 20 | COPY ./service.controller.plug/package.json . 21 | RUN npm install 22 | 23 | # Move one level up so node_modules is not overwritten by a mounted directory 24 | RUN mv node_modules /usr/src/node_modules 25 | 26 | # Bundle app source 27 | COPY ./service.controller.plug . 28 | 29 | # Expose ports for web access and debugging 30 | EXPOSE 80 9229 31 | 32 | CMD nodemon --inspect=0.0.0.0:9229 --watch . --watch /usr/src/libraries/javascript index.js 33 | -------------------------------------------------------------------------------- /service.controller.plug/domain/Hs100.js: -------------------------------------------------------------------------------- 1 | const { Device } = require("../../libraries/javascript/device"); 2 | 3 | class Hs100 extends Device { 4 | constructor(config) { 5 | super(config); 6 | 7 | this.state = { 8 | power: { type: "bool" } 9 | }; 10 | } 11 | 12 | transform(state) { 13 | const t = {}; 14 | 15 | if ("power" in state) { 16 | t.power = Boolean(state.power); 17 | } 18 | 19 | return t; 20 | } 21 | } 22 | 23 | exports = module.exports = Hs100; 24 | -------------------------------------------------------------------------------- /service.controller.plug/domain/Hs110.js: -------------------------------------------------------------------------------- 1 | const Hs100 = require("./Hs100"); 2 | 3 | class Hs110 extends Hs100 { 4 | constructor(config) { 5 | super(config); 6 | 7 | this.state.watts = { 8 | watts: { type: "float", immutable: true } 9 | }; 10 | 11 | // Generate powerset of device identifiers 12 | const ps = getPowerset(Object.keys(config.attributes.devices)); 13 | 14 | // Array of objects {combination: [id_1, ..., id_n], watts: x} 15 | this.powerMap = []; 16 | for (const combination of ps) { 17 | const watts = combination.reduce( 18 | (sum, identifier) => sum + config.attributes.devices[identifier], 19 | 0 20 | ); 21 | this.powerMap.push({ combination, watts }); 22 | } 23 | } 24 | 25 | applyState(state) { 26 | super.applyState(state); 27 | 28 | let closest = null; 29 | let combination = null; 30 | 31 | for (const map of this.powerMap) { 32 | const d = Math.abs(map.watts - state.watts); 33 | 34 | if (closest === null || d < closest) { 35 | closest = d; 36 | combination = map.combination; 37 | } 38 | } 39 | 40 | this.combination = combination; 41 | } 42 | } 43 | 44 | /** 45 | * Return the powerset of the given array. 46 | * E.g., given [1, 2, 3], will return [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]. 47 | * 48 | * @param {array} arr 49 | * @return {array} powerset 50 | */ 51 | const getPowerset = arr => { 52 | let ps = [[]]; 53 | for (let i = 0; i < arr.length; i++) { 54 | for (let j = 0, len = ps.length; j < len; j++) { 55 | ps.push(ps[j].concat(arr[i])); 56 | } 57 | } 58 | return ps; 59 | }; 60 | 61 | exports = module.exports = Hs110; 62 | -------------------------------------------------------------------------------- /service.controller.plug/index.js: -------------------------------------------------------------------------------- 1 | const bootstrap = require("../libraries/javascript/bootstrap"); 2 | const dao = require("./dao"); 3 | const router = require("../libraries/javascript/router"); 4 | const config = require("../libraries/javascript/config"); 5 | require("./routes"); 6 | 7 | // Create and initialise a Service object 8 | const serviceName = "service.controller.plug"; 9 | bootstrap(serviceName) 10 | .then(() => dao.fetchAllState()) 11 | .then(() => { 12 | router.listen(); 13 | 14 | // Poll for state changes 15 | if (config.get("polling.enabled", false)) { 16 | dao.watch(config.get("polling.interval", 30000)); 17 | } 18 | }) 19 | .catch(err => { 20 | console.error("Error initialising service", err); 21 | }); 22 | -------------------------------------------------------------------------------- /service.controller.plug/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "service.controller.plug", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "author": "Jake Wright", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "^1.14.2" 13 | }, 14 | "dependencies": { 15 | "lodash": "^4.17.11", 16 | "tplink-smarthome-api": "1.2.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /service.controller.plug/prod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | ENV NODE_ENV=production 3 | 4 | # Add the libraries 5 | RUN mkdir -p /usr/src/libraries/javascript 6 | COPY ./libraries/javascript /usr/src/libraries/javascript 7 | WORKDIR /usr/src/libraries/javascript 8 | RUN npm install 9 | 10 | # Create app directory 11 | RUN mkdir -p /usr/src/app 12 | WORKDIR /usr/src/app 13 | 14 | # Install app dependencies 15 | COPY ./service.controller.plug/package.json . 16 | RUN npm install 17 | 18 | # Bundle app source 19 | COPY ./service.controller.plug . 20 | 21 | CMD [ "npm", "run", "start" ] 22 | -------------------------------------------------------------------------------- /service.controller.plug/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require("../../libraries/javascript/router"); 2 | const device = require("./device"); 3 | 4 | router.use("/device/:deviceId", device.load); 5 | 6 | router.get("/device/:deviceId", device.get); 7 | router.patch("/device/:deviceId", device.update); 8 | 9 | router.get("/provide-state/:deviceId", device.provideState); 10 | -------------------------------------------------------------------------------- /services/api-gateway/config.prod.yaml: -------------------------------------------------------------------------------- 1 | apis: 2 | device-registry: 3 | name: Device Registry 4 | prefix: device-registry 5 | upstream_url: http://device-registry 6 | allow_cross_origin: true 7 | plugins: 8 | - name: retry 9 | enabled: true 10 | config: 11 | attempts: 3 12 | 13 | dmx: 14 | name: DMX Controller 15 | prefix: dmx 16 | upstream_url: http://dmx 17 | allow_cross_origin: true 18 | plugins: 19 | - name: retry 20 | enabled: true 21 | config: 22 | attempts: 3 23 | -------------------------------------------------------------------------------- /services/api-gateway/prod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM jakewright/drawbridge 2 | COPY ./services/api-gateway/config.prod.yaml /config/config.yaml 3 | -------------------------------------------------------------------------------- /services/device-registry/deviceregistry.def: -------------------------------------------------------------------------------- 1 | import device "../../libraries/go/device/device.def" 2 | 3 | service DeviceRegistry { 4 | path = "device-registry" 5 | 6 | rpc GetDevice(GetDeviceRequest) GetDeviceResponse { 7 | method = "GET" 8 | path = "/device" 9 | } 10 | 11 | rpc ListDevices(ListDevicesRequest) ListDevicesResponse { 12 | method = "GET" 13 | path = "/devices" 14 | } 15 | 16 | rpc GetRoom(GetRoomRequest) GetRoomResponse { 17 | method = "GET" 18 | path = "/room" 19 | } 20 | 21 | rpc ListRooms(ListRoomsRequest) ListRoomsResponse { 22 | method = "GET" 23 | path = "/rooms" 24 | } 25 | } 26 | 27 | // ---- Domain messages ---- // 28 | 29 | message Room { 30 | string id (required) 31 | string name (required) 32 | []device.Header devices 33 | } 34 | 35 | // ---- Request & Response messages ---- // 36 | 37 | message GetDeviceRequest { 38 | string device_id (required) 39 | } 40 | 41 | message GetDeviceResponse { 42 | device.Header device_header 43 | } 44 | 45 | message ListDevicesRequest { 46 | string controller_name 47 | } 48 | 49 | message ListDevicesResponse { 50 | []device.Header device_headers 51 | } 52 | 53 | message GetRoomRequest { 54 | string room_id (required) 55 | } 56 | 57 | message GetRoomResponse { 58 | Room room 59 | } 60 | 61 | message ListRoomsRequest {} 62 | 63 | message ListRoomsResponse { 64 | []Room rooms 65 | } 66 | -------------------------------------------------------------------------------- /services/device-registry/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 5 | "github.com/jakewright/home-automation/libraries/go/slog" 6 | "github.com/jakewright/home-automation/services/device-registry/repository" 7 | "github.com/jakewright/home-automation/services/device-registry/routes" 8 | ) 9 | 10 | //go:generate jrpc deviceregistry.def 11 | 12 | func main() { 13 | conf := struct { 14 | ConfigFilename string 15 | }{} 16 | 17 | svc := bootstrap.Init(&bootstrap.Opts{ 18 | ServiceName: "device-registry", 19 | Config: &conf, 20 | }) 21 | 22 | if conf.ConfigFilename == "" { 23 | slog.Panicf("configFilename is empty") 24 | } 25 | 26 | dr, err := repository.NewDeviceRepository(conf.ConfigFilename) 27 | if err != nil { 28 | slog.Panicf("failed to init device repository: %v", err) 29 | } 30 | 31 | rr, err := repository.NewRoomRepository(conf.ConfigFilename) 32 | if err != nil { 33 | slog.Panicf("failed to init room repository: %v", err) 34 | } 35 | 36 | routes.Register(svc, &routes.Controller{ 37 | DeviceRepository: dr, 38 | RoomRepository: rr, 39 | }) 40 | 41 | svc.Run() 42 | } 43 | -------------------------------------------------------------------------------- /services/device-registry/routes/device.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | devicedef "github.com/jakewright/home-automation/libraries/go/device/def" 7 | "github.com/jakewright/home-automation/libraries/go/oops" 8 | deviceregistrydef "github.com/jakewright/home-automation/services/device-registry/def" 9 | ) 10 | 11 | // ListDevices lists all devices known by the registry. Results can be filtered by controller name. 12 | func (c *Controller) ListDevices(ctx context.Context, body *deviceregistrydef.ListDevicesRequest) (*deviceregistrydef.ListDevicesResponse, error) { 13 | var devices []*devicedef.Header 14 | var err error 15 | 16 | if controllerName, set := body.GetControllerName(); set { 17 | devices, err = c.DeviceRepository.FindByController(controllerName) 18 | } else { 19 | devices, err = c.DeviceRepository.FindAll() 20 | } 21 | if err != nil { 22 | return nil, oops.WithMessage(err, "failed to find devices") 23 | } 24 | 25 | return &deviceregistrydef.ListDevicesResponse{ 26 | DeviceHeaders: devices, 27 | }, nil 28 | } 29 | 30 | // GetDevice returns a specific device by ID 31 | func (c *Controller) GetDevice(ctx context.Context, body *deviceregistrydef.GetDeviceRequest) (*deviceregistrydef.GetDeviceResponse, error) { 32 | device, err := c.DeviceRepository.Find(body.GetDeviceId()) 33 | if err != nil { 34 | return nil, oops.WithMessage(err, "failed to find device %q", body.GetDeviceId()) 35 | } 36 | if device == nil { 37 | return nil, oops.NotFound("device %q not found", body.GetDeviceId()) 38 | } 39 | 40 | return &deviceregistrydef.GetDeviceResponse{ 41 | DeviceHeader: device, 42 | }, nil 43 | } 44 | -------------------------------------------------------------------------------- /services/device-registry/routes/handler.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import "github.com/jakewright/home-automation/services/device-registry/repository" 4 | 5 | // Controller handles requests 6 | type Controller struct { 7 | DeviceRepository *repository.DeviceRepository 8 | RoomRepository *repository.RoomRepository 9 | } 10 | -------------------------------------------------------------------------------- /services/dmx/dmx.def: -------------------------------------------------------------------------------- 1 | import device "../../libraries/go/device/device.def" 2 | 3 | service DMX { 4 | path = "dmx" 5 | 6 | rpc GetMegaParProfile(GetMegaParProfileRequest) MegaParProfileResponse { 7 | method = "GET" 8 | path = "/mega-par-profile" 9 | } 10 | 11 | rpc UpdateMegaParProfile(UpdateMegaParProfileRequest) MegaParProfileResponse { 12 | method = "PATCH" 13 | path = "/mega-par-profile" 14 | } 15 | } 16 | 17 | message MegaParProfileState { 18 | bool power 19 | uint8 brightness 20 | rgb color 21 | uint8 strobe 22 | } 23 | 24 | message GetMegaParProfileRequest { 25 | string device_id (required) 26 | } 27 | 28 | message UpdateMegaParProfileRequest { 29 | string device_id (required) 30 | MegaParProfileState state 31 | } 32 | 33 | message MegaParProfileResponse { 34 | device.Header header 35 | map[string]device.Property properties 36 | MegaParProfileState state 37 | } 38 | -------------------------------------------------------------------------------- /services/dmx/dmx/client.go: -------------------------------------------------------------------------------- 1 | package dmx 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/jakewright/home-automation/services/dmx/domain" 8 | ) 9 | 10 | // GetSetter is an interface for interacting with a DMX universe 11 | type GetSetter interface { 12 | GetValues(ctx context.Context) ([512]byte, error) 13 | SetValues(ctx context.Context, values [512]byte) error 14 | } 15 | 16 | // Client can get and set DMX values for all universes 17 | type Client struct { 18 | getSetters map[domain.UniverseNumber]GetSetter 19 | mu *sync.Mutex 20 | } 21 | 22 | // NewClient returns a new client 23 | func NewClient() *Client { 24 | return &Client{ 25 | getSetters: make(map[domain.UniverseNumber]GetSetter), 26 | mu: &sync.Mutex{}, 27 | } 28 | } 29 | 30 | // AddGetSetter adds a GetSetter to the client 31 | func (c *Client) AddGetSetter(un domain.UniverseNumber, gs GetSetter) { 32 | c.mu.Lock() 33 | defer c.mu.Unlock() 34 | c.getSetters[un] = gs 35 | } 36 | 37 | // GetValues returns the DMX values for the specified universe 38 | func (c *Client) GetValues(ctx context.Context, un domain.UniverseNumber) ([512]byte, error) { 39 | c.mu.Lock() 40 | defer c.mu.Unlock() 41 | 42 | return c.getSetters[un].GetValues(ctx) 43 | } 44 | 45 | // SetValues sets the DMX values for the specified universe 46 | func (c *Client) SetValues(ctx context.Context, un domain.UniverseNumber, values [512]byte) error { 47 | c.mu.Lock() 48 | defer c.mu.Unlock() 49 | 50 | return c.getSetters[un].SetValues(ctx, values) 51 | } 52 | -------------------------------------------------------------------------------- /services/dmx/dmx/mock.go: -------------------------------------------------------------------------------- 1 | package dmx 2 | 3 | import "context" 4 | 5 | // MockGetSetter can be used in tests 6 | type MockGetSetter struct { 7 | Values [512]byte 8 | } 9 | 10 | var _ GetSetter = (*MockGetSetter)(nil) 11 | 12 | // GetValues returns the current values 13 | func (m *MockGetSetter) GetValues(ctx context.Context) ([512]byte, error) { 14 | return m.Values, nil 15 | } 16 | 17 | // SetValues replaces the values with a new slice 18 | func (m *MockGetSetter) SetValues(ctx context.Context, values [512]byte) error { 19 | m.Values = values 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /services/dmx/domain/base.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | devicedef "github.com/jakewright/home-automation/libraries/go/device/def" 5 | "github.com/jakewright/home-automation/libraries/go/oops" 6 | ) 7 | 8 | type baseFixture struct { 9 | *devicedef.Header 10 | universeNumber UniverseNumber 11 | offsetValue int 12 | } 13 | 14 | // setHeader sets the fixture's header and pulls the offset out of the attributes 15 | func (f *baseFixture) setHeader(h *devicedef.Header) error { 16 | universeNumber, ok := h.Attributes["universe"].(float64) 17 | if !ok { 18 | return oops.PreconditionFailed("universe number not found in %s device header", h.Id) 19 | } 20 | 21 | offset, ok := h.Attributes["offset"].(float64) 22 | if !ok { 23 | return oops.PreconditionFailed("offset not found in %s device header", h.Id) 24 | } 25 | 26 | f.Header = h 27 | f.universeNumber = UniverseNumber(universeNumber) 28 | f.offsetValue = int(offset) 29 | return nil 30 | } 31 | 32 | // ID returns the device ID 33 | func (f *baseFixture) ID() string { return f.Header.GetId() } 34 | 35 | // UniverseNumber returns the fixture's universe number 36 | func (f *baseFixture) UniverseNumber() UniverseNumber { return f.universeNumber } 37 | 38 | // offset returns the fixture's offset into the channel space 39 | func (f *baseFixture) offset() int { return f.offsetValue } 40 | -------------------------------------------------------------------------------- /services/dmx/domain/fixture_test.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestValidateFixtures(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | name string 14 | fixtures []Fixture 15 | want bool 16 | }{ 17 | { 18 | name: "No fixtures", 19 | fixtures: []Fixture{}, 20 | want: true, 21 | }, 22 | { 23 | name: "One fixture", 24 | fixtures: []Fixture{ 25 | &MockFixture{Ofs: 0, Len: 5}, 26 | }, 27 | want: true, 28 | }, 29 | { 30 | name: "Three non-overlapping fixtures", 31 | fixtures: []Fixture{ 32 | &MockFixture{Ofs: 0, Len: 7}, 33 | &MockFixture{Ofs: 7, Len: 5}, 34 | &MockFixture{Ofs: 12, Len: 10}, 35 | }, 36 | want: true, 37 | }, 38 | { 39 | name: "Overlapping fixtures", 40 | fixtures: []Fixture{ 41 | &MockFixture{Ofs: 0, Len: 7}, 42 | &MockFixture{Ofs: 6, Len: 5}, 43 | }, 44 | want: false, 45 | }, 46 | } 47 | 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | t.Parallel() 51 | 52 | err := ValidateFixtures(tt.fixtures) 53 | if tt.want { 54 | require.NoError(t, err) 55 | } else { 56 | require.Error(t, err) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /services/dmx/domain/fixtures.json: -------------------------------------------------------------------------------- 1 | { 2 | "MegaParProfile": { 3 | "properties": { 4 | "power": { 5 | "type": "bool" 6 | }, 7 | "brightness": { 8 | "type": "int", 9 | "min": 0, 10 | "max": 255 11 | }, 12 | "rgb": { 13 | "type": "rgb" 14 | }, 15 | "strobe": { 16 | "type": "int", 17 | "min": 0, 18 | "max": 255 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /services/dmx/domain/mock_fixture.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | devicedef "github.com/jakewright/home-automation/libraries/go/device/def" 5 | ) 6 | 7 | // MockFixture can be used in tests 8 | type MockFixture struct { 9 | IDValue string 10 | UN UniverseNumber 11 | Ofs int 12 | Len int 13 | } 14 | 15 | var _ Fixture = (*MockFixture)(nil) 16 | 17 | // ID returns the ID 18 | func (f *MockFixture) ID() string { 19 | return f.IDValue 20 | } 21 | 22 | // UniverseNumber returns the universe number 23 | func (f *MockFixture) UniverseNumber() UniverseNumber { 24 | return f.UN 25 | } 26 | 27 | // SetProperties sets the properties 28 | func (f *MockFixture) SetProperties(m map[string]interface{}) error { 29 | panic("implement me") 30 | } 31 | 32 | // offset returns the offset 33 | func (f *MockFixture) offset() int { 34 | return f.Ofs 35 | } 36 | 37 | // length returns the length 38 | func (f *MockFixture) length() int { 39 | return f.Len 40 | } 41 | 42 | // hydrate is not implemented 43 | func (f *MockFixture) hydrate(values []byte) error { 44 | panic("implement me") 45 | } 46 | 47 | // dmxValues is not implemented 48 | func (f *MockFixture) dmxValues() []byte { 49 | panic("implement me") 50 | } 51 | 52 | // setHeader is not implemented 53 | func (f *MockFixture) setHeader(header *devicedef.Header) error { 54 | panic("implement me") 55 | } 56 | -------------------------------------------------------------------------------- /services/dmx/domain/universe.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | // UniverseNumber is the OLA universe number that can range from 1 to 65535 4 | // https://www.openlighting.org/ola/get-help/ola-faq/#Universes 5 | type UniverseNumber uint16 6 | 7 | // Universe represents a 512 channel space 8 | type Universe struct { 9 | // values holds the current value of all channels. This should not be read 10 | // directly as it may be out of date. It is updated with the latest values 11 | // from the fixtures when DMXValues() is called. 12 | values [512]byte 13 | 14 | // fixtures is a set of fixtures in the universe. Note that this does not 15 | // need to be a complete set. Only the fixtures that you care about 16 | // changing need to be added to a universe. Values of all other channels 17 | // will be maintained. 18 | fixtures []Fixture 19 | } 20 | 21 | // NewUniverse returns a new universe containing the given fixtures. Each 22 | // fixture is hydrated with the relevant slice of values from the byte array. 23 | func NewUniverse(values [512]byte, fixtures ...Fixture) (*Universe, error) { 24 | // Hydrate each fixture 25 | for _, f := range fixtures { 26 | slice := values[f.offset() : f.offset()+f.length()] 27 | if err := f.hydrate(slice); err != nil { 28 | return nil, err 29 | } 30 | } 31 | 32 | return &Universe{ 33 | values: values, 34 | fixtures: fixtures, 35 | }, nil 36 | } 37 | 38 | // DMXValues returns the value of all channels in the universe 39 | func (u *Universe) DMXValues() [512]byte { 40 | for _, f := range u.fixtures { 41 | copy(u.values[f.offset():], f.dmxValues()) 42 | } 43 | return u.values 44 | } 45 | -------------------------------------------------------------------------------- /services/dmx/routes/router.go: -------------------------------------------------------------------------------- 1 | // Code generated by jrpc. DO NOT EDIT. 2 | 3 | package routes 4 | 5 | import ( 6 | context "context" 7 | 8 | taxi "github.com/jakewright/home-automation/libraries/go/taxi" 9 | def "github.com/jakewright/home-automation/services/dmx/def" 10 | ) 11 | 12 | // taxiRouter is an interface implemented by taxi.Router 13 | type taxiRouter interface { 14 | HandleFunc(method, path string, handler func(context.Context, taxi.Decoder) (interface{}, error)) 15 | } 16 | 17 | type handler interface { 18 | GetMegaParProfile(ctx context.Context, body *def.GetMegaParProfileRequest) (*def.MegaParProfileResponse, error) 19 | UpdateMegaParProfile(ctx context.Context, body *def.UpdateMegaParProfileRequest) (*def.MegaParProfileResponse, error) 20 | } 21 | 22 | // Register adds the service's routes to the router 23 | func Register(r taxiRouter, h handler) { 24 | r.HandleFunc("GET", "/mega-par-profile", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 25 | body := &def.GetMegaParProfileRequest{} 26 | if err := decode(body); err != nil { 27 | return nil, err 28 | } 29 | 30 | if err := body.Validate(); err != nil { 31 | return nil, err 32 | } 33 | 34 | return h.GetMegaParProfile(ctx, body) 35 | }) 36 | 37 | r.HandleFunc("PATCH", "/mega-par-profile", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 38 | body := &def.UpdateMegaParProfileRequest{} 39 | if err := decode(body); err != nil { 40 | return nil, err 41 | } 42 | 43 | if err := body.Validate(); err != nil { 44 | return nil, err 45 | } 46 | 47 | return h.UpdateMegaParProfile(ctx, body) 48 | }) 49 | 50 | } 51 | -------------------------------------------------------------------------------- /services/dmx/routes/setup_test.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 8 | ) 9 | 10 | func TestMain(m *testing.M) { 11 | bootstrap.SetupTest() 12 | os.Exit(m.Run()) 13 | } 14 | -------------------------------------------------------------------------------- /services/dummy/README.md: -------------------------------------------------------------------------------- 1 | # dummy 2 | 3 | This service hosts a collection of endpoints that are useful when testing things. 4 | -------------------------------------------------------------------------------- /services/dummy/def/types.go: -------------------------------------------------------------------------------- 1 | // Code generated by jrpc. DO NOT EDIT. 2 | 3 | package dummydef 4 | 5 | // LogRequest is defined in the .def file 6 | type LogRequest struct { 7 | } 8 | 9 | // Validate returns an error if any of the fields have bad values 10 | func (m *LogRequest) Validate() error { 11 | return nil 12 | } 13 | 14 | // LogResponse is defined in the .def file 15 | type LogResponse struct { 16 | } 17 | 18 | // Validate returns an error if any of the fields have bad values 19 | func (m *LogResponse) Validate() error { 20 | return nil 21 | } 22 | 23 | // PanicRequest is defined in the .def file 24 | type PanicRequest struct { 25 | } 26 | 27 | // Validate returns an error if any of the fields have bad values 28 | func (m *PanicRequest) Validate() error { 29 | return nil 30 | } 31 | 32 | // PanicResponse is defined in the .def file 33 | type PanicResponse struct { 34 | } 35 | 36 | // Validate returns an error if any of the fields have bad values 37 | func (m *PanicResponse) Validate() error { 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /services/dummy/dummy.def: -------------------------------------------------------------------------------- 1 | service Dummy { 2 | path = "dummy" 3 | 4 | rpc Log(LogRequest) LogResponse { 5 | method = "POST" 6 | path = "/log" 7 | } 8 | 9 | rpc Panic(PanicRequest) PanicResponse { 10 | method = "POST" 11 | path = "/panic" 12 | } 13 | } 14 | 15 | message LogRequest {} 16 | 17 | message LogResponse {} 18 | 19 | message PanicRequest {} 20 | 21 | message PanicResponse {} 22 | -------------------------------------------------------------------------------- /services/dummy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 5 | "github.com/jakewright/home-automation/services/dummy/routes" 6 | ) 7 | 8 | //go:generate jrpc dummy.def 9 | 10 | func main() { 11 | conf := struct{}{} 12 | 13 | svc := bootstrap.Init(&bootstrap.Opts{ 14 | ServiceName: "dummy", 15 | Config: &conf, 16 | }) 17 | 18 | routes.Register(svc, &routes.Controller{}) 19 | 20 | svc.Run() 21 | } 22 | -------------------------------------------------------------------------------- /services/dummy/routes/handler.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/slog" 7 | def "github.com/jakewright/home-automation/services/dummy/def" 8 | ) 9 | 10 | // Controller handles requests 11 | type Controller struct{} 12 | 13 | // Log emits a log line 14 | func (c Controller) Log(ctx context.Context, body *def.LogRequest) (*def.LogResponse, error) { 15 | slog.Infof("This is a log line", map[string]string{ 16 | "foo": "bar", 17 | }) 18 | return &def.LogResponse{}, nil 19 | } 20 | 21 | // Panic panics (it will be recovered by the framework) 22 | func (c Controller) Panic(ctx context.Context, body *def.PanicRequest) (*def.PanicResponse, error) { 23 | panic("Panic! at the Handler") 24 | } 25 | -------------------------------------------------------------------------------- /services/dummy/routes/router.go: -------------------------------------------------------------------------------- 1 | // Code generated by jrpc. DO NOT EDIT. 2 | 3 | package routes 4 | 5 | import ( 6 | context "context" 7 | 8 | taxi "github.com/jakewright/home-automation/libraries/go/taxi" 9 | def "github.com/jakewright/home-automation/services/dummy/def" 10 | ) 11 | 12 | // taxiRouter is an interface implemented by taxi.Router 13 | type taxiRouter interface { 14 | HandleFunc(method, path string, handler func(context.Context, taxi.Decoder) (interface{}, error)) 15 | } 16 | 17 | type handler interface { 18 | Log(ctx context.Context, body *def.LogRequest) (*def.LogResponse, error) 19 | Panic(ctx context.Context, body *def.PanicRequest) (*def.PanicResponse, error) 20 | } 21 | 22 | // Register adds the service's routes to the router 23 | func Register(r taxiRouter, h handler) { 24 | r.HandleFunc("POST", "/log", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 25 | body := &def.LogRequest{} 26 | if err := decode(body); err != nil { 27 | return nil, err 28 | } 29 | 30 | if err := body.Validate(); err != nil { 31 | return nil, err 32 | } 33 | 34 | return h.Log(ctx, body) 35 | }) 36 | 37 | r.HandleFunc("POST", "/panic", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 38 | body := &def.PanicRequest{} 39 | if err := decode(body); err != nil { 40 | return nil, err 41 | } 42 | 43 | if err := body.Validate(); err != nil { 44 | return nil, err 45 | } 46 | 47 | return h.Panic(ctx, body) 48 | }) 49 | 50 | } 51 | -------------------------------------------------------------------------------- /services/event-bus/config/default.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /services/event-bus/config/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 80, 3 | "redis": { 4 | "host": "redis", 5 | "port": 6379 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /services/event-bus/config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 80, 3 | "redis": { 4 | "host": "192.168.1.100", 5 | "port": 6379 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /services/event-bus/dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | ENV NODE_ENV=development 3 | 4 | # Install nodemon 5 | RUN npm install -g nodemon 6 | 7 | # Add the libraries 8 | RUN mkdir -p /usr/src/libraries/javascript 9 | COPY ./libraries/javascript /usr/src/libraries/javascript 10 | WORKDIR /usr/src/libraries/javascript 11 | RUN npm install 12 | 13 | # Move one level up so node_modules is not overwritten by a mounted directory 14 | RUN mv node_modules /usr/src/libraries/node_modules 15 | 16 | # Create app directory 17 | RUN mkdir -p /usr/src/app 18 | WORKDIR /usr/src/app 19 | 20 | # Install app dependencies 21 | COPY ./service.event-bus/package.json . 22 | RUN npm install 23 | 24 | # Move one level up so node_modules is not overwritten by a mounted directory 25 | RUN mv node_modules /usr/src/node_modules 26 | 27 | # Bundle app source 28 | COPY ./service.event-bus . 29 | 30 | # Expose ports for web access and debugging 31 | EXPOSE 80 9229 32 | 33 | CMD nodemon --inspect=0.0.0.0:9229 --watch . --watch /usr/src/libraries/javascript index.js 34 | -------------------------------------------------------------------------------- /services/event-bus/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const http = require("http"); 3 | const WebSocket = require("ws"); 4 | 5 | const bootstrap = require("../libraries/javascript/bootstrap"); 6 | const config = require("../libraries/javascript/config"); 7 | const firehose = require("../libraries/javascript/firehose"); 8 | 9 | bootstrap("service.event-bus") 10 | .then(() => { 11 | // Create an express app 12 | const app = express(); 13 | 14 | // Create a websocket server 15 | const server = http.createServer(app); 16 | const wss = new WebSocket.Server({ server }); 17 | 18 | firehose.subscribe("device-state-changed.*", (channel, message) => { 19 | console.log(`Message received on channel '${channel}'\n${message}\n`); 20 | 21 | message = JSON.parse(message); 22 | 23 | wss.clients.forEach(client => { 24 | if (client.readyState === WebSocket.OPEN) { 25 | client.send(JSON.stringify({ channel, message })); 26 | } 27 | }); 28 | }); 29 | 30 | // Start the server 31 | server.listen(config.get("port", 80), () => { 32 | console.log("Listening on port %d", server.address().port); 33 | }); 34 | }) 35 | .catch(err => { 36 | console.error("Error initialising service", err); 37 | }); 38 | -------------------------------------------------------------------------------- /services/event-bus/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "home-automation-event-bus", 3 | "version": "1.0.0", 4 | "description": "Listens to redis pubsub messages and relays them over web sockets", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "author": "Jake Wright", 10 | "license": "MIT", 11 | "dependencies": { 12 | "express": "^4.16.2", 13 | "ws": "^3.2.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /services/event-bus/prod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | ENV NODE_ENV=production 3 | 4 | # Add the libraries 5 | RUN mkdir -p /usr/src/libraries/javascript 6 | COPY ./libraries/javascript /usr/src/libraries/javascript 7 | WORKDIR /usr/src/libraries/javascript 8 | RUN npm install 9 | 10 | # Create app directory 11 | RUN mkdir -p /usr/src/app 12 | WORKDIR /usr/src/app 13 | 14 | # Install app dependencies 15 | COPY ./service.event-bus/package.json . 16 | RUN npm install 17 | 18 | # Bundle app source 19 | COPY ./service.event-bus . 20 | 21 | CMD [ "npm", "run", "start" ] 22 | -------------------------------------------------------------------------------- /services/fake-ola/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/jakewright/home-automation/libraries/go/bootstrap" 4 | 5 | const serviceName = "fake-dmx" 6 | 7 | func main() { 8 | svc := bootstrap.Init(&bootstrap.Opts{ 9 | ServiceName: serviceName, 10 | }) 11 | 12 | svc.Run() 13 | } 14 | -------------------------------------------------------------------------------- /services/fake-ola/routes/handler.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import "github.com/jakewright/home-automation/libraries/go/taxi" 4 | 5 | func HandleGetDMX() { 6 | taxi.NewRouter() 7 | } 8 | -------------------------------------------------------------------------------- /services/fluentd/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM fluent/fluentd:v1.11.5-debian-1.0 2 | 3 | # Use root account to use apt 4 | USER root 5 | 6 | RUN buildDeps="sudo make gcc g++ libc-dev" \ 7 | && apt-get update \ 8 | && apt-get install -y --no-install-recommends $buildDeps \ 9 | && sudo apt-get install -y --no-install-recommends libmariadb-dev \ 10 | && sudo gem install fluent-plugin-mongo fluent-plugin-grok-parser fluent-plugin-kubernetes_metadata_filter \ 11 | && sudo gem sources --clear-all \ 12 | && SUDO_FORCE_REMOVE=yes \ 13 | apt-get purge -y --auto-remove \ 14 | -o APT::AutoRemove::RecommendsImportant=false \ 15 | $buildDeps \ 16 | && rm -rf /var/lib/apt/lists/* \ 17 | && rm -rf /tmp/* /var/tmp/* /usr/lib/ruby/gems/*/cache/*.gem 18 | 19 | COPY fluent.conf /fluentd/etc/ 20 | COPY entrypoint.sh /bin/ 21 | -------------------------------------------------------------------------------- /services/fluentd/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | #source vars if file exists 4 | DEFAULT=/etc/default/fluentd 5 | 6 | if [ -r $DEFAULT ]; then 7 | set -o allexport 8 | . $DEFAULT 9 | set +o allexport 10 | fi 11 | 12 | # If the user has supplied only arguments append them to `fluentd` command 13 | if [ "${1#-}" != "$1" ]; then 14 | set -- fluentd "$@" 15 | fi 16 | 17 | # If user does not supply config file or plugins, use the default 18 | if [ "$1" = "fluentd" ]; then 19 | if ! echo $@ | grep ' \-c' ; then 20 | set -- "$@" -c /fluentd/etc/${FLUENTD_CONF} 21 | fi 22 | 23 | if ! echo $@ | grep ' \-p' ; then 24 | set -- "$@" -p /fluentd/plugins 25 | fi 26 | fi 27 | 28 | exec "$@" 29 | -------------------------------------------------------------------------------- /services/infrared/domain/device.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import deviceregistrydef "github.com/jakewright/home-automation/services/device-registry/def" 4 | 5 | type Device interface { 6 | ID() string 7 | Copy() Device 8 | } 9 | 10 | func NewDeviceFromDeviceHeader(header *deviceregistrydef.DeviceHeader) (Device, error) { 11 | // todo 12 | } 13 | -------------------------------------------------------------------------------- /services/infrared/infrared.def: -------------------------------------------------------------------------------- 1 | import device "../../libraries/go/device/device.def" 2 | 3 | service Infrared { 4 | path = "infrared" 5 | 6 | rpc GetDevice(GetDeviceRequest) GetDeviceResponse { 7 | method = "GET" 8 | path = "/device" 9 | } 10 | 11 | rpc UpdateDevice(UpdateDeviceRequest) UpdateDeviceResponse { 12 | method = "PATCH" 13 | path = "/device" 14 | } 15 | } 16 | 17 | message GetDeviceRequest { 18 | string device_id (required) 19 | } 20 | 21 | message GetDeviceResponse { 22 | // device.Device device 23 | } 24 | 25 | message UpdateDeviceRequest { 26 | string device_id (required) 27 | map[string]any state 28 | } 29 | 30 | message UpdateDeviceResponse { 31 | // device.Device device 32 | } 33 | -------------------------------------------------------------------------------- /services/infrared/ir/ir.go: -------------------------------------------------------------------------------- 1 | package ir 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/jakewright/home-automation/libraries/go/distsync" 8 | lircproxydef "github.com/jakewright/home-automation/services/lirc-proxy/def" 9 | ) 10 | 11 | type Instruction func(context.Context, lircproxydef.LircProxyService) error 12 | 13 | func Wait(ms int) Instruction { 14 | return func(context.Context, lircproxydef.LircProxyService) error { 15 | time.Sleep(time.Millisecond * time.Duration(ms)) 16 | return nil 17 | } 18 | } 19 | 20 | func Key(device, key string) Instruction { 21 | return func(ctx context.Context, lirc lircproxydef.LircProxyService) error { 22 | return send(ctx, device, key) 23 | } 24 | } 25 | 26 | type IRSend struct { 27 | LIRC lircproxydef.LircProxyService 28 | } 29 | 30 | func (s *IRSend) Execute(ctx context.Context, ins []Instruction) error { 31 | // dsync will take care of time outs 32 | // and context cancellations for us 33 | lock, err := distsync.Lock(ctx, "ir") 34 | if err != nil { 35 | return err 36 | } 37 | defer lock.Unlock() 38 | 39 | for _, instruction := range ins { 40 | if err := instruction(ctx); err != nil { 41 | return err 42 | } 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func send( 49 | ctx context.Context, 50 | lirc lircproxydef.LircProxyService, 51 | device, key string, 52 | ) error { 53 | if _, err := lirc.SendOnce(ctx, &lircproxydef.SendOnceRequest{ 54 | Device: device, 55 | Key: key, 56 | }).Wait(); err != nil { 57 | return 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /services/infrared/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 5 | "github.com/jakewright/home-automation/services/infrared/routes" 6 | ) 7 | 8 | //go:generate jrpc infrared.def 9 | 10 | func main() { 11 | svc := bootstrap.Init(&bootstrap.Opts{ 12 | ServiceName: "service.infrared", 13 | Firehose: true, 14 | }) 15 | 16 | r := routes.Register(svc, &routes.Controller{ 17 | Repository: nil, 18 | IR: nil, 19 | }) 20 | 21 | svc.Run(r) 22 | } 23 | -------------------------------------------------------------------------------- /services/infrared/repository/device.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/jakewright/home-automation/services/infrared/domain" 7 | ) 8 | 9 | // DeviceRepository holds devices 10 | type DeviceRepository struct { 11 | devices map[string]domain.Device 12 | mux *sync.RWMutex 13 | } 14 | 15 | // New returns a new DeviceRepository 16 | func New() *DeviceRepository { 17 | return &DeviceRepository{ 18 | devices: make(map[string]domain.Device), 19 | mux: &sync.RWMutex{}, 20 | } 21 | } 22 | 23 | // Find returns the device with the given ID 24 | func (r *DeviceRepository) Find(id string) domain.Device { 25 | r.mux.RLock() 26 | defer r.mux.RUnlock() 27 | 28 | d, ok := r.devices[id] 29 | if !ok { 30 | return nil 31 | } 32 | 33 | return d.Copy() 34 | } 35 | 36 | // AddDevice adds the given device to the 37 | // repository if it does not already exist 38 | func (r *DeviceRepository) AddDevice(d domain.Device) { 39 | r.mux.Lock() 40 | defer r.mux.Unlock() 41 | 42 | if _, ok := r.devices[d.ID()]; ok { 43 | return 44 | } 45 | 46 | r.devices[d.ID()] = d 47 | } 48 | 49 | // Save adds the given device to the repository 50 | // replacing any existing device with the same ID 51 | func (r *DeviceRepository) Save(d domain.Device) { 52 | r.mux.Lock() 53 | defer r.mux.Unlock() 54 | 55 | r.devices[d.ID()] = d 56 | } 57 | -------------------------------------------------------------------------------- /services/infrared/repository/load.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/oops" 7 | deviceregistrydef "github.com/jakewright/home-automation/services/device-registry/def" 8 | "github.com/jakewright/home-automation/services/infrared/domain" 9 | ) 10 | 11 | // Loader loads device metadata and instantiates devices 12 | type Loader struct { 13 | ServiceName string 14 | Repository *DeviceRepository 15 | } 16 | 17 | func (l *Loader) FetchDevices(ctx context.Context) error { 18 | rsp, err := (&deviceregistrydef.ListDevicesRequest{ 19 | ControllerName: l.ServiceName, 20 | }).Do(ctx) 21 | if err != nil { 22 | return oops.WithMessage(err, "failed to fetch devices") 23 | } 24 | 25 | for _, device := range rsp.DeviceHeaders { 26 | // Sanity check the controller name 27 | if device.ControllerName != l.ServiceName { 28 | return oops.InternalService("device %s is not for this controller", device.Id) 29 | } 30 | 31 | device, err := domain.NewDeviceFromDeviceHeader(device) 32 | if err != nil { 33 | return oops.WithMessage(err, "failed to create device") 34 | } 35 | 36 | l.Repository.AddDevice(device) 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /services/infrared/routes/handler.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | infrareddef "github.com/jakewright/home-automation/services/infrared/def" 7 | "github.com/jakewright/home-automation/services/infrared/ir" 8 | "github.com/jakewright/home-automation/services/infrared/repository" 9 | ) 10 | 11 | type executor interface { 12 | Execute(context.Context, []ir.Instruction) error 13 | } 14 | 15 | type Controller struct { 16 | Repository *repository.DeviceRepository 17 | IR executor 18 | } 19 | 20 | func (c *Controller) HandleGetDevice(r *Request, body *infrareddef.GetDeviceRequest) *dmx 21 | -------------------------------------------------------------------------------- /services/infrared/routes/router.go: -------------------------------------------------------------------------------- 1 | // Code generated by jrpc. DO NOT EDIT. 2 | 3 | package routes 4 | 5 | import ( 6 | context "context" 7 | 8 | taxi "github.com/jakewright/home-automation/libraries/go/taxi" 9 | def "github.com/jakewright/home-automation/services/infrared/def" 10 | ) 11 | 12 | // taxiRouter is an interface implemented by taxi.Router 13 | type taxiRouter interface { 14 | HandleFunc(method, path string, handler func(context.Context, taxi.Decoder) (interface{}, error)) 15 | } 16 | 17 | type handler interface { 18 | GetDevice(ctx context.Context, body *def.GetDeviceRequest) (*def.GetDeviceResponse, error) 19 | UpdateDevice(ctx context.Context, body *def.UpdateDeviceRequest) (*def.UpdateDeviceResponse, error) 20 | } 21 | 22 | // Register adds the service's routes to the router 23 | func Register(r taxiRouter, h handler) { 24 | r.HandleFunc("GET", "/device", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 25 | body := &def.GetDeviceRequest{} 26 | if err := decode(body); err != nil { 27 | return nil, err 28 | } 29 | 30 | if err := body.Validate(); err != nil { 31 | return nil, err 32 | } 33 | 34 | return h.GetDevice(ctx, body) 35 | }) 36 | 37 | r.HandleFunc("PATCH", "/device", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 38 | body := &def.UpdateDeviceRequest{} 39 | if err := decode(body); err != nil { 40 | return nil, err 41 | } 42 | 43 | if err := body.Validate(); err != nil { 44 | return nil, err 45 | } 46 | 47 | return h.UpdateDevice(ctx, body) 48 | }) 49 | 50 | } 51 | -------------------------------------------------------------------------------- /services/lirc-proxy/README.md: -------------------------------------------------------------------------------- 1 | # lirc-proxy 2 | 3 | This service can be run on a Raspberry Pi with [LIRC](https://www.lirc.org) installed. It proxies requests through to the `irsend` program. 4 | -------------------------------------------------------------------------------- /services/lirc-proxy/handler/router.go: -------------------------------------------------------------------------------- 1 | // Code generated by jrpc. DO NOT EDIT. 2 | 3 | package handler 4 | 5 | import ( 6 | context "context" 7 | 8 | taxi "github.com/jakewright/home-automation/libraries/go/taxi" 9 | def "github.com/jakewright/home-automation/services/lirc-proxy/def" 10 | ) 11 | 12 | // taxiRouter is an interface implemented by taxi.Router 13 | type taxiRouter interface { 14 | HandleFunc(method, path string, handler func(context.Context, taxi.Decoder) (interface{}, error)) 15 | } 16 | 17 | type handler interface { 18 | SendOnce(ctx context.Context, body *def.SendOnceRequest) (*def.SendOnceResponse, error) 19 | } 20 | 21 | // RegisterRoutes adds the service's routes to the router 22 | func RegisterRoutes(r taxiRouter, h handler) { 23 | r.HandleFunc("POST", "/send-once", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 24 | body := &def.SendOnceRequest{} 25 | if err := decode(body); err != nil { 26 | return nil, err 27 | } 28 | 29 | if err := body.Validate(); err != nil { 30 | return nil, err 31 | } 32 | 33 | return h.SendOnce(ctx, body) 34 | }) 35 | 36 | } 37 | -------------------------------------------------------------------------------- /services/lirc-proxy/lirc_proxy.def: -------------------------------------------------------------------------------- 1 | service LircProxy { 2 | path = "lirc-proxy" 3 | 4 | rpc SendOnce(SendOnceRequest) SendOnceResponse { 5 | method = "POST" 6 | path = "/send-once" 7 | } 8 | } 9 | 10 | message SendOnceRequest { 11 | string device (required) 12 | string key (required) 13 | } 14 | 15 | message SendOnceResponse {} 16 | -------------------------------------------------------------------------------- /services/lirc-proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 5 | "github.com/jakewright/home-automation/services/lirc-proxy/routes" 6 | ) 7 | 8 | //go:generate jrpc lirc_proxy.def 9 | 10 | func main() { 11 | conf := struct{}{} 12 | 13 | svc := bootstrap.Init(&bootstrap.Opts{ 14 | ServiceName: "lirc-proxy", 15 | Config: &conf, 16 | }) 17 | 18 | routes.Register(svc, &routes.Controller{}) 19 | 20 | svc.Run() 21 | } 22 | -------------------------------------------------------------------------------- /services/lirc-proxy/routes/router.go: -------------------------------------------------------------------------------- 1 | // Code generated by jrpc. DO NOT EDIT. 2 | 3 | package routes 4 | 5 | import ( 6 | context "context" 7 | 8 | taxi "github.com/jakewright/home-automation/libraries/go/taxi" 9 | def "github.com/jakewright/home-automation/services/lirc-proxy/def" 10 | ) 11 | 12 | // taxiRouter is an interface implemented by taxi.Router 13 | type taxiRouter interface { 14 | HandleFunc(method, path string, handler func(context.Context, taxi.Decoder) (interface{}, error)) 15 | } 16 | 17 | type handler interface { 18 | SendOnce(ctx context.Context, body *def.SendOnceRequest) (*def.SendOnceResponse, error) 19 | } 20 | 21 | // Register adds the service's routes to the router 22 | func Register(r taxiRouter, h handler) { 23 | r.HandleFunc("POST", "/send-once", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 24 | body := &def.SendOnceRequest{} 25 | if err := decode(body); err != nil { 26 | return nil, err 27 | } 28 | 29 | if err := body.Validate(); err != nil { 30 | return nil, err 31 | } 32 | 33 | return h.SendOnce(ctx, body) 34 | }) 35 | 36 | } 37 | -------------------------------------------------------------------------------- /services/log/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 5 | "github.com/jakewright/home-automation/libraries/go/router" 6 | "github.com/jakewright/home-automation/libraries/go/slog" 7 | "github.com/jakewright/home-automation/services/log/repository" 8 | "github.com/jakewright/home-automation/services/log/routes" 9 | "github.com/jakewright/home-automation/services/log/watch" 10 | ) 11 | 12 | func main() { 13 | conf := struct { 14 | LogDirectory string 15 | TemplateDirectory string 16 | }{} 17 | 18 | svc := bootstrap.Init(&bootstrap.Opts{ 19 | ServiceName: "service.log", 20 | Config: &conf, 21 | }) 22 | 23 | if conf.LogDirectory == "" { 24 | slog.Panicf("logDirectory not set in config") 25 | } 26 | 27 | if conf.TemplateDirectory == "" { 28 | slog.Panicf("templateDirectory not set in config") 29 | } 30 | 31 | logRepository := &repository.LogRepository{ 32 | LogDirectory: conf.LogDirectory, 33 | } 34 | 35 | watcher := &watch.Watcher{ 36 | LogRepository: logRepository, 37 | } 38 | 39 | _ = &routes.Handler{ 40 | TemplateDirectory: conf.TemplateDirectory, 41 | LogRepository: logRepository, 42 | Watcher: watcher, 43 | } 44 | 45 | r := router.New(svc) 46 | // r.Get("/", h.HandleRead) 47 | // r.Get("/ws", h.HandleWebSocket) 48 | // r.Post("/write", h.HandleWrite) 49 | 50 | svc.Run(r, watcher) 51 | } 52 | -------------------------------------------------------------------------------- /services/log/prod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13 2 | 3 | WORKDIR /app 4 | COPY . . 5 | 6 | RUN CGO_ENABLED=0 GOOS=linux go install ./service.log 7 | 8 | FROM alpine:latest 9 | WORKDIR /root/ 10 | COPY --from=0 /go/bin/service.log . 11 | COPY ./service.log/templates /templates 12 | CMD ["./service.log"] 13 | -------------------------------------------------------------------------------- /services/log/routes/handler.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/services/log/repository" 5 | "github.com/jakewright/home-automation/services/log/watch" 6 | ) 7 | 8 | // Handler handles requests 9 | type Handler struct { 10 | TemplateDirectory string 11 | LogRepository *repository.LogRepository 12 | Watcher *watch.Watcher 13 | } 14 | -------------------------------------------------------------------------------- /services/log/routes/write.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/jakewright/home-automation/libraries/go/oops" 8 | "github.com/jakewright/home-automation/libraries/go/slog" 9 | "github.com/jakewright/home-automation/libraries/go/taxi" 10 | ) 11 | 12 | type writeRequest struct { 13 | Timestamp time.Time 14 | Severity slog.Severity 15 | Message string 16 | Metadata map[string]string 17 | } 18 | 19 | // HandleWrite writes a slog line for testing purposes 20 | func (h *Handler) HandleWrite(w http.ResponseWriter, r *http.Request) { 21 | body := writeRequest{} 22 | if err := taxi.DecodeRequest(r, &body); err != nil { 23 | _ = taxi.WriteError(w, err) 24 | return 25 | } 26 | 27 | if slog.DefaultLogger == nil { 28 | _ = taxi.WriteError(w, oops.InternalService("Default logger is nil")) 29 | return 30 | } 31 | 32 | if body.Timestamp.IsZero() { 33 | body.Timestamp = time.Now() 34 | } 35 | 36 | if int(body.Severity) == 0 { 37 | body.Severity = slog.InfoSeverity 38 | } 39 | 40 | if body.Message == "" { 41 | body.Message = "This is a log event" 42 | } 43 | 44 | if len(body.Metadata) == 0 { 45 | body.Metadata = map[string]string{"foo": "bar"} 46 | } 47 | 48 | event := &slog.Event{ 49 | Timestamp: body.Timestamp, 50 | Severity: body.Severity, 51 | Message: body.Message, 52 | Metadata: body.Metadata, 53 | } 54 | 55 | slog.DefaultLogger.Log(event) 56 | 57 | _ = taxi.WriteSuccess(w, event) 58 | } 59 | -------------------------------------------------------------------------------- /services/mongo/docker-entrypoint-initdb.d/init-dev.js: -------------------------------------------------------------------------------- 1 | db.createUser({ 2 | user: "fluentd", 3 | pwd: "fluentd", 4 | roles: [ 5 | { 6 | role: "readWrite", 7 | db: "home_automation_logs" 8 | } 9 | ] 10 | }); 11 | -------------------------------------------------------------------------------- /services/ping/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 5 | ) 6 | 7 | func main() { 8 | svc := bootstrap.Init(&bootstrap.Opts{ 9 | ServiceName: "service.ping", 10 | }) 11 | 12 | // The router has a default ping handler defined 13 | // in: libraries/go/router/middleware.go 14 | svc.Run() 15 | } 16 | -------------------------------------------------------------------------------- /services/scene/consumer/scene_set_test.go: -------------------------------------------------------------------------------- 1 | package consumer 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/assert" 7 | 8 | "github.com/jakewright/home-automation/services/scene/domain" 9 | ) 10 | 11 | func Test_constructStages(t *testing.T) { 12 | scene := &domain.Scene{ 13 | Actions: []*domain.Action{ 14 | {Stage: 1, Sequence: 1}, 15 | {Stage: 1, Sequence: 2}, 16 | {Stage: 1, Sequence: 3}, 17 | {Stage: 2, Sequence: 1}, 18 | {Stage: 3, Sequence: 1}, 19 | {Stage: 3, Sequence: 1}, 20 | {Stage: 6, Sequence: 2}, 21 | }, 22 | } 23 | 24 | stages := constructStages(scene) 25 | 26 | assert.Equal(t, 4, len(stages)) 27 | assert.Equal(t, 3, len(stages[0])) 28 | assert.Equal(t, 1, len(stages[1])) 29 | assert.Equal(t, 2, len(stages[2])) 30 | assert.Equal(t, 1, len(stages[3])) 31 | } 32 | -------------------------------------------------------------------------------- /services/scene/dao/dao.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | // 4 | //func SetScene(scene *domain.Scene) { 5 | // database.Create(scene) 6 | //} 7 | -------------------------------------------------------------------------------- /services/scene/def/firehose.go: -------------------------------------------------------------------------------- 1 | // Code generated by jrpc. DO NOT EDIT. 2 | 3 | package scenedef 4 | 5 | import ( 6 | context "context" 7 | 8 | "github.com/jakewright/home-automation/libraries/go/firehose" 9 | "github.com/jakewright/home-automation/libraries/go/oops" 10 | ) 11 | 12 | // Publish publishes the event to the Firehose 13 | func (m *SetSceneEvent) Publish(ctx context.Context, p firehose.Publisher) error { 14 | if err := m.Validate(); err != nil { 15 | return err 16 | } 17 | 18 | return p.Publish(ctx, "set-scene", m) 19 | } 20 | 21 | // SetSceneEventHandler implements the necessary functions to be a Firehose handler 22 | type SetSceneEventHandler func(*SetSceneEvent) firehose.Result 23 | 24 | // HandleEvent handles the Firehose event 25 | func (h SetSceneEventHandler) HandleEvent(ctx context.Context, decode firehose.Decoder) firehose.Result { 26 | var body SetSceneEvent 27 | if err := decode(&body); err != nil { 28 | return firehose.Discard(oops.WithMessage(err, "failed to unmarshal payload")) 29 | } 30 | return h(&body) 31 | } 32 | -------------------------------------------------------------------------------- /services/scene/domain/scene.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | scenedef "github.com/jakewright/home-automation/services/scene/def" 7 | ) 8 | 9 | // Scene represents a set of actions 10 | type Scene struct { 11 | ID uint32 12 | Name string 13 | OwnerID uint32 14 | Actions []*Action 15 | CreatedAt time.Time 16 | UpdatedAt time.Time 17 | } 18 | 19 | // ToProto marshals to the proto type 20 | func (s *Scene) ToProto() *scenedef.Scene { 21 | actions := make([]*scenedef.Action, len(s.Actions)) 22 | for i, a := range s.Actions { 23 | actions[i] = a.ToProto() 24 | } 25 | 26 | return &scenedef.Scene{ 27 | Id: s.ID, 28 | Name: s.Name, 29 | OwnerId: s.OwnerID, 30 | Actions: actions, 31 | CreatedAt: s.CreatedAt, 32 | UpdatedAt: s.UpdatedAt, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /services/scene/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 5 | "github.com/jakewright/home-automation/libraries/go/firehose" 6 | "github.com/jakewright/home-automation/services/scene/consumer" 7 | "github.com/jakewright/home-automation/services/scene/routes" 8 | ) 9 | 10 | //go:generate jrpc scene.def 11 | 12 | func main() { 13 | svc := bootstrap.Init(&bootstrap.Opts{ 14 | ServiceName: "service.scene", 15 | }) 16 | 17 | firehose.Subscribe(consumer.HandleSetSceneEvent) 18 | 19 | routes.Register(svc, &routes.Controller{ 20 | Database: svc.Database(), 21 | }) 22 | 23 | svc.Run() 24 | } 25 | -------------------------------------------------------------------------------- /services/scene/prod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13 2 | 3 | WORKDIR /app 4 | COPY . . 5 | 6 | RUN CGO_ENABLED=0 GOOS=linux go install ./service.scene 7 | 8 | FROM alpine:latest 9 | WORKDIR /root/ 10 | COPY --from=0 /go/bin/service.scene . 11 | CMD ["./service.scene"] 12 | -------------------------------------------------------------------------------- /services/scene/routes/handler.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import "github.com/jakewright/home-automation/libraries/go/database" 4 | 5 | // Controller handles requests 6 | type Controller struct { 7 | Database database.Database 8 | } 9 | -------------------------------------------------------------------------------- /services/scene/routes/scene_create.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/slog" 7 | scenedef "github.com/jakewright/home-automation/services/scene/def" 8 | "github.com/jakewright/home-automation/services/scene/domain" 9 | ) 10 | 11 | // CreateScene persists a new scene 12 | func (c *Controller) CreateScene(ctx context.Context, body *scenedef.CreateSceneRequest) (*scenedef.CreateSceneResponse, error) { 13 | actions := make([]*domain.Action, len(body.Actions)) 14 | for i, a := range body.Actions { 15 | actions[i] = &domain.Action{ 16 | Stage: int(a.Stage), 17 | Sequence: int(a.Sequence), 18 | Func: a.Func, 19 | ControllerName: a.ControllerName, 20 | DeviceID: a.DeviceId, 21 | Command: a.Command, 22 | Property: a.Property, 23 | PropertyValue: a.PropertyValue, 24 | PropertyType: a.PropertyType, 25 | } 26 | 27 | if err := actions[i].Validate(); err != nil { 28 | return nil, err 29 | } 30 | } 31 | 32 | scene := &domain.Scene{ 33 | Name: body.Name, 34 | Actions: actions, 35 | } 36 | 37 | if err := c.Database.Create(scene); err != nil { 38 | return nil, err 39 | } 40 | 41 | slog.Infof("Created new scene %d", scene.ID) 42 | 43 | return &scenedef.CreateSceneResponse{ 44 | Scene: scene.ToProto(), 45 | }, nil 46 | } 47 | -------------------------------------------------------------------------------- /services/scene/routes/scene_delete.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/oops" 7 | "github.com/jakewright/home-automation/libraries/go/slog" 8 | scenedef "github.com/jakewright/home-automation/services/scene/def" 9 | "github.com/jakewright/home-automation/services/scene/domain" 10 | ) 11 | 12 | // DeleteScene deletes a scene and associated actions 13 | func (c *Controller) DeleteScene(ctx context.Context, body *scenedef.DeleteSceneRequest) (*scenedef.DeleteSceneResponse, error) { 14 | if body.SceneId == 0 { 15 | return nil, oops.BadRequest("scene_id empty") 16 | } 17 | 18 | // Delete the scene 19 | if err := c.Database.Delete(&domain.Scene{}, body.SceneId); err != nil { 20 | return nil, err 21 | } 22 | 23 | slog.Infof("Deleted scene %d", body.SceneId) 24 | return &scenedef.DeleteSceneResponse{}, nil 25 | } 26 | -------------------------------------------------------------------------------- /services/scene/routes/scene_list.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | scenedef "github.com/jakewright/home-automation/services/scene/def" 7 | "github.com/jakewright/home-automation/services/scene/domain" 8 | ) 9 | 10 | // ListScenes lists all scenes in the database 11 | func (c *Controller) ListScenes(ctx context.Context, body *scenedef.ListScenesRequest) (*scenedef.ListScenesResponse, error) { 12 | where := make(map[string]interface{}) 13 | if body.OwnerId > 0 { 14 | where["owner_id"] = body.OwnerId 15 | } 16 | 17 | var scenes []*domain.Scene 18 | if err := c.Database.Find(&scenes, where); err != nil { 19 | return nil, err 20 | } 21 | 22 | protos := make([]*scenedef.Scene, len(scenes)) 23 | for i, s := range scenes { 24 | protos[i] = s.ToProto() 25 | } 26 | 27 | return &scenedef.ListScenesResponse{ 28 | Scenes: protos, 29 | }, nil 30 | } 31 | -------------------------------------------------------------------------------- /services/scene/routes/scene_read.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/oops" 7 | scenedef "github.com/jakewright/home-automation/services/scene/def" 8 | "github.com/jakewright/home-automation/services/scene/domain" 9 | ) 10 | 11 | // ReadScene returns the scene with the given ID 12 | func (c *Controller) ReadScene(ctx context.Context, body *scenedef.ReadSceneRequest) (*scenedef.ReadSceneResponse, error) { 13 | scene := &domain.Scene{} 14 | if err := c.Database.Find(&scene, body.SceneId); err != nil { 15 | return nil, oops.WithMessage(err, "failed to find") 16 | } 17 | 18 | return &scenedef.ReadSceneResponse{ 19 | Scene: scene.ToProto(), 20 | }, nil 21 | } 22 | -------------------------------------------------------------------------------- /services/scene/routes/scene_set.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/oops" 7 | scenedef "github.com/jakewright/home-automation/services/scene/def" 8 | "github.com/jakewright/home-automation/services/scene/domain" 9 | ) 10 | 11 | // SetScene emits an event to trigger the scene to be set asynchronously 12 | func (c *Controller) SetScene(ctx context.Context, body *scenedef.SetSceneRequest) (*scenedef.SetSceneResponse, error) { 13 | scene := &domain.Scene{} 14 | if err := c.Database.Find(&scene, body.SceneId); err != nil { 15 | return nil, err 16 | } 17 | 18 | if scene == nil { 19 | return nil, oops.NotFound("Scene not found") 20 | } 21 | 22 | if err := (&scenedef.SetSceneEvent{ 23 | SceneId: body.SceneId, 24 | }).Publish(); err != nil { 25 | return nil, err 26 | } 27 | 28 | return &scenedef.SetSceneResponse{}, nil 29 | } 30 | -------------------------------------------------------------------------------- /services/scene/schema/mock_data.sql: -------------------------------------------------------------------------------- 1 | USE home_automation; 2 | 3 | INSERT INTO `service_scene_scenes` (`id`, `name`, `owner_id`) VALUES 4 | (1, 'Hue light test', 1); 5 | 6 | INSERT INTO `service_scene_actions` (`scene_id`, `stage`, `sequence`, `func`, `controller_name`, `device_id`, `command`, `property`, `property_value`, `property_type`) VALUES 7 | (1, 1, 1, '', 'service.controller.hue', 'jake-desk-lamp', '', 'power', 'false', 'boolean'), 8 | (1, 1, 2, 'sleep 2s', '', '', '', '', '', ''), 9 | (1, 2, 1, '', 'service.controller.hue', 'jake-desk-lamp', '', 'power', 'true', 'boolean'); 10 | -------------------------------------------------------------------------------- /services/scene/schema/schema.sql: -------------------------------------------------------------------------------- 1 | USE home_automation; 2 | 3 | CREATE TABLE IF NOT EXISTS service_scene_scenes ( 4 | id INT AUTO_INCREMENT PRIMARY KEY, 5 | name VARCHAR(64) NOT NULL, 6 | owner_id INT NOT NULL, 7 | 8 | created_at TIMESTAMP DEFAULT NOW(), 9 | updated_at TIMESTAMP DEFAULT NOW() ON UPDATE NOW() 10 | ); 11 | 12 | CREATE TABLE IF NOT EXISTS service_scene_actions ( 13 | scene_id INT NOT NULL, 14 | stage INT NOT NULL, -- Ordering within the scene 15 | sequence INT NOT NULL, -- Ordering within the stage 16 | 17 | func VARCHAR(64), -- e.g. sleep() 18 | controller_name VARCHAR(64), 19 | device_id VARCHAR(64), 20 | command VARCHAR(64), 21 | property VARCHAR(64), 22 | property_value VARCHAR(64), 23 | property_type VARCHAR(64), 24 | 25 | created_at TIMESTAMP DEFAULT NOW(), 26 | updated_at TIMESTAMP DEFAULT NOW() ON UPDATE NOW(), 27 | 28 | PRIMARY KEY (scene_id, stage, sequence), 29 | 30 | FOREIGN KEY (scene_id) REFERENCES service_scene_scenes(id) 31 | ON UPDATE CASCADE ON DELETE CASCADE 32 | ); 33 | -------------------------------------------------------------------------------- /services/schedule/domain/action.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | // Action is a single change to make to a device 4 | type Action struct { 5 | ScheduleID int 6 | Property string 7 | Value string 8 | Type string 9 | } 10 | -------------------------------------------------------------------------------- /services/schedule/domain/actor.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | // Actor represents a device to perform an action on 4 | type Actor struct { 5 | Identifier string `json:"identifier"` 6 | ControllerName string `json:"controller_name"` 7 | } 8 | -------------------------------------------------------------------------------- /services/schedule/domain/schedule.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jinzhu/gorm" 7 | ) 8 | 9 | // Schedule wraps a set of rules and a set of actions 10 | type Schedule struct { 11 | gorm.Model 12 | 13 | // ActorID is the ID of the controller to act upon 14 | ActorID string 15 | 16 | // Actions is the list of actions to perform 17 | Actions []Action 18 | 19 | // StartTime is the earliest time that the schedule can run. 20 | // N.b. it might not run at this time if the rules do not permit. 21 | StartTime time.Time 22 | 23 | // NextRun is a cache of the next run time 24 | NextRun time.Time 25 | 26 | // Count is the number of times the schedule should run. 27 | // A value of -1 will run the schedule ad infinitum. 28 | Count int 29 | 30 | // Until is the end date of the schedule 31 | Until time.Time 32 | 33 | // Rules define when this schedule should run 34 | //Rules []ScheduleRule 35 | } 36 | -------------------------------------------------------------------------------- /services/schedule/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /services/user/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 5 | "github.com/jakewright/home-automation/services/user/routes" 6 | ) 7 | 8 | //go:generate jrpc user.def 9 | 10 | func main() { 11 | svc := bootstrap.Init(&bootstrap.Opts{ 12 | ServiceName: "service.user", 13 | }) 14 | 15 | routes.Register(svc, &routes.Controller{}) 16 | 17 | svc.Run() 18 | } 19 | -------------------------------------------------------------------------------- /services/user/prod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13 2 | 3 | WORKDIR /app 4 | COPY . . 5 | 6 | RUN CGO_ENABLED=0 GOOS=linux go install ./service.user 7 | 8 | FROM alpine:latest 9 | WORKDIR /root/ 10 | COPY --from=0 /go/bin/service.user . 11 | CMD ["./service.user"] 12 | -------------------------------------------------------------------------------- /services/user/routes/handler.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | // Controller handles requests 4 | type Controller struct{} 5 | -------------------------------------------------------------------------------- /services/user/routes/router.go: -------------------------------------------------------------------------------- 1 | // Code generated by jrpc. DO NOT EDIT. 2 | 3 | package routes 4 | 5 | import ( 6 | context "context" 7 | 8 | taxi "github.com/jakewright/home-automation/libraries/go/taxi" 9 | def "github.com/jakewright/home-automation/services/user/def" 10 | ) 11 | 12 | // taxiRouter is an interface implemented by taxi.Router 13 | type taxiRouter interface { 14 | HandleFunc(method, path string, handler func(context.Context, taxi.Decoder) (interface{}, error)) 15 | } 16 | 17 | type handler interface { 18 | GetUser(ctx context.Context, body *def.GetUserRequest) (*def.GetUserResponse, error) 19 | ListUsers(ctx context.Context, body *def.ListUsersRequest) (*def.ListUsersResponse, error) 20 | } 21 | 22 | // Register adds the service's routes to the router 23 | func Register(r taxiRouter, h handler) { 24 | r.HandleFunc("GET", "/user", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 25 | body := &def.GetUserRequest{} 26 | if err := decode(body); err != nil { 27 | return nil, err 28 | } 29 | 30 | if err := body.Validate(); err != nil { 31 | return nil, err 32 | } 33 | 34 | return h.GetUser(ctx, body) 35 | }) 36 | 37 | r.HandleFunc("GET", "/users", func(ctx context.Context, decode taxi.Decoder) (interface{}, error) { 38 | body := &def.ListUsersRequest{} 39 | if err := decode(body); err != nil { 40 | return nil, err 41 | } 42 | 43 | if err := body.Validate(); err != nil { 44 | return nil, err 45 | } 46 | 47 | return h.ListUsers(ctx, body) 48 | }) 49 | 50 | } 51 | -------------------------------------------------------------------------------- /services/user/routes/user_get.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/database" 7 | "github.com/jakewright/home-automation/libraries/go/oops" 8 | userdef "github.com/jakewright/home-automation/services/user/def" 9 | ) 10 | 11 | // GetUser reads a user by ID 12 | func (c *Controller) GetUser(ctx context.Context, body *userdef.GetUserRequest) (*userdef.GetUserResponse, error) { 13 | user := &userdef.User{} 14 | if err := database.Find(user, body.UserId); err != nil { 15 | return nil, oops.WithMessage(err, "failed to find") 16 | } 17 | 18 | return &userdef.GetUserResponse{ 19 | User: user, 20 | }, nil 21 | } 22 | -------------------------------------------------------------------------------- /services/user/routes/user_list.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/database" 7 | "github.com/jakewright/home-automation/libraries/go/oops" 8 | userdef "github.com/jakewright/home-automation/services/user/def" 9 | ) 10 | 11 | // ListUsers lists all users 12 | func (c *Controller) ListUsers(ctx context.Context, body *userdef.ListUsersRequest) (*userdef.ListUsersResponse, error) { 13 | var users []*userdef.User 14 | if err := database.Find(&users); err != nil { 15 | return nil, oops.WithMessage(err, "failed to find") 16 | } 17 | 18 | return &userdef.ListUsersResponse{ 19 | Users: users, 20 | }, nil 21 | } 22 | -------------------------------------------------------------------------------- /services/user/schema/mock_data.sql: -------------------------------------------------------------------------------- 1 | USE home_automation; 2 | 3 | INSERT INTO `service_user_users` (`id`, `name`) VALUES 4 | (1, 'Jake'), 5 | (2, 'Claudio'); 6 | -------------------------------------------------------------------------------- /services/user/schema/schema.sql: -------------------------------------------------------------------------------- 1 | USE home_automation; 2 | 3 | CREATE TABLE IF NOT EXISTS service_user_users ( 4 | id INT AUTO_INCREMENT PRIMARY KEY, 5 | name VARCHAR(64), 6 | 7 | created_at TIMESTAMP DEFAULT NOW(), 8 | updated_at TIMESTAMP DEFAULT NOW() ON UPDATE NOW() 9 | ); 10 | -------------------------------------------------------------------------------- /services/user/user.def: -------------------------------------------------------------------------------- 1 | service User { 2 | path = "user" 3 | 4 | rpc GetUser(GetUserRequest) GetUserResponse { 5 | method = "GET" 6 | path = "/user" 7 | } 8 | 9 | rpc ListUsers(ListUsersRequest) ListUsersResponse { 10 | method = "GET" 11 | path = "/users" 12 | } 13 | } 14 | 15 | // ---- Domain messages ---- // 16 | 17 | message User { 18 | uint32 id 19 | string name 20 | time created_at 21 | time updated_at 22 | } 23 | 24 | // ---- Request & Response messages ---- // 25 | 26 | message GetUserRequest { 27 | uint32 user_id 28 | } 29 | 30 | message GetUserResponse { 31 | User user 32 | } 33 | 34 | message ListUsersRequest {} 35 | 36 | message ListUsersResponse { 37 | []User users 38 | } 39 | -------------------------------------------------------------------------------- /tools/bolt/README.md: -------------------------------------------------------------------------------- 1 | # Run 2 | 3 | Run is a tool for running home-automation services locally. 4 | 5 | ## Package structure 6 | 7 | 8 | ``` 9 | +-->service <--+ 10 | | | 11 | | | 12 | v v 13 | golang compose } run systems 14 | ^ ^ 15 | | | 16 | v | 17 | docker | 18 | ^ | 19 | | | 20 | v v 21 | +------------------------+ 22 | | Docker | 23 | +------------------------+ 24 | ``` 25 | 26 | https://textik.com/#827c8a3c02be9468 27 | -------------------------------------------------------------------------------- /tools/bolt/cmd/build.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/jakewright/home-automation/tools/bolt/pkg/compose" 7 | "github.com/jakewright/home-automation/tools/bolt/pkg/service" 8 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 9 | ) 10 | 11 | var ( 12 | buildCmd = &cobra.Command{ 13 | Use: "build [foo] [bar]...", 14 | Short: "build a service", 15 | Args: cobra.MinimumNArgs(1), 16 | Run: func(cmd *cobra.Command, args []string) { 17 | services := service.Expand(args) 18 | 19 | c, err := compose.New() 20 | if err != nil { 21 | output.Fatal("Failed to init compose: %v", err) 22 | } 23 | 24 | if err := c.Build(services); err != nil { 25 | output.Fatal("Failed to build: %v", err) 26 | } 27 | }, 28 | } 29 | ) 30 | 31 | func init() { 32 | rootCmd.AddCommand(buildCmd) 33 | } 34 | -------------------------------------------------------------------------------- /tools/bolt/cmd/db.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var ( 6 | dbCmd = &cobra.Command{ 7 | Use: "db [command]", 8 | Short: "perform database operations", 9 | } 10 | ) 11 | 12 | func init() { 13 | rootCmd.AddCommand(dbCmd) 14 | } 15 | -------------------------------------------------------------------------------- /tools/bolt/cmd/firehose.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var ( 6 | firehoseCmd = &cobra.Command{ 7 | Use: "firehose [command]", 8 | Short: "interact with the Firehose", 9 | } 10 | ) 11 | 12 | func init() { 13 | rootCmd.AddCommand(firehoseCmd) 14 | } 15 | -------------------------------------------------------------------------------- /tools/bolt/cmd/firehose_publish.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-redis/redis/v7" 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/jakewright/home-automation/libraries/go/firehose" 12 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 13 | ) 14 | 15 | var ( 16 | firehosePublishCmd = &cobra.Command{ 17 | Use: "publish [channel] [json payload]", 18 | Short: "publish raw JSON messages to the Firehose", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | if len(args) < 2 { 21 | _ = cmd.Usage() 22 | return 23 | } 24 | 25 | fmt.Println(args[1]) 26 | 27 | var msg interface{} 28 | if err := json.Unmarshal([]byte(args[1]), &msg); err != nil { 29 | output.Fatal("Failed to unmarshal JSON: %v", err) 30 | } 31 | 32 | addr := "localhost:6379" 33 | redisClient := redis.NewClient(&redis.Options{ 34 | Addr: addr, 35 | Password: "", 36 | DB: 0, 37 | MaxRetries: 1, 38 | MinRetryBackoff: time.Second, 39 | MaxRetryBackoff: time.Second * 5, 40 | }) 41 | 42 | defer func() { _ = redisClient.Close() }() 43 | 44 | c := firehose.NewStreamsClient(redisClient) 45 | 46 | if err := c.Publish(args[0], msg); err != nil { 47 | output.Fatal("Failed to publish: %v", err) 48 | } 49 | 50 | output.Info("Messaged published to channel %q", args[0]).Success() 51 | }, 52 | } 53 | ) 54 | 55 | func init() { 56 | firehoseCmd.AddCommand(firehosePublishCmd) 57 | } 58 | -------------------------------------------------------------------------------- /tools/bolt/cmd/logs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/jakewright/home-automation/tools/bolt/pkg/compose" 7 | "github.com/jakewright/home-automation/tools/bolt/pkg/service" 8 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 9 | ) 10 | 11 | var ( 12 | logsCmd = &cobra.Command{ 13 | Use: "logs [foo] [bar]", 14 | Short: "show logs for a set of services (default: all services)", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | services := service.Expand(args) 17 | 18 | c, err := compose.New() 19 | if err != nil { 20 | output.Fatal("Failed to init compose: %v", err) 21 | } 22 | 23 | if err := c.Logs(services); err != nil { 24 | output.Fatal("Failed to output logs: %v", err) 25 | } 26 | }, 27 | } 28 | ) 29 | 30 | func init() { 31 | rootCmd.AddCommand(logsCmd) 32 | } 33 | -------------------------------------------------------------------------------- /tools/bolt/cmd/restart.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/jakewright/home-automation/tools/bolt/pkg/compose" 7 | "github.com/jakewright/home-automation/tools/bolt/pkg/service" 8 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 9 | ) 10 | 11 | var ( 12 | restartCmd = &cobra.Command{ 13 | Use: "restart [foo] [bar]", 14 | Short: "restart a service", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | build, err := cmd.Flags().GetBool("build") 17 | if err != nil { 18 | output.Fatal("Failed to parse build flag: %v", err) 19 | } 20 | 21 | services := service.Expand(args) 22 | 23 | c, err := compose.New() 24 | if err != nil { 25 | output.Fatal("Failed to init compose: %v", err) 26 | } 27 | 28 | if err := c.Stop(services); err != nil { 29 | output.Fatal("Failed to stop services: %v", err) 30 | } 31 | 32 | if build { 33 | if err := c.Build(services); err != nil { 34 | output.Fatal("Failed to build: %v", err) 35 | } 36 | } 37 | 38 | if err := service.Run(c, services); err != nil { 39 | output.Fatal("Failed to run: %v", err) 40 | } 41 | }, 42 | } 43 | ) 44 | 45 | func init() { 46 | rootCmd.AddCommand(restartCmd) 47 | restartCmd.Flags().BoolP("build", "b", false, "rebuild the service before running") 48 | } 49 | -------------------------------------------------------------------------------- /tools/bolt/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "sort" 8 | "strings" 9 | "text/tabwriter" 10 | 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/jakewright/home-automation/tools/bolt/pkg/config" 14 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 15 | "github.com/jakewright/home-automation/tools/libraries/cache" 16 | ) 17 | 18 | var ( 19 | rootCmd = &cobra.Command{ 20 | Use: "bolt [command]", 21 | Short: "A tool to run home automation services locally", 22 | } 23 | ) 24 | 25 | // Execute runs the root command 26 | func Execute() { 27 | if err := rootCmd.Execute(); err != nil { 28 | fmt.Println(err) 29 | os.Exit(1) 30 | } 31 | } 32 | 33 | func init() { 34 | if err := cache.Init("run"); err != nil { 35 | output.Fatal("Failed to initialise toolutils: %v", err) 36 | } 37 | 38 | if err := config.Init(); err != nil { 39 | output.Fatal("Failed to initialise config: %v", err) 40 | } 41 | 42 | // Append the groups to the usage info 43 | b := bytes.Buffer{} 44 | w := tabwriter.NewWriter(&b, 0, 4, 5, ' ', 0) 45 | for name, services := range config.Get().Groups { 46 | sort.Strings(services) 47 | if _, err := fmt.Fprintf(w, " %s\t%s\n", 48 | name, 49 | strings.Join(services, ", "), 50 | ); err != nil { 51 | panic(err) 52 | } 53 | } 54 | 55 | if err := w.Flush(); err != nil { 56 | panic(err) 57 | } 58 | 59 | groupInfo := "\nGroups:\n" + b.String() 60 | rootCmd.SetHelpTemplate(rootCmd.HelpTemplate() + groupInfo) 61 | } 62 | -------------------------------------------------------------------------------- /tools/bolt/cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/jakewright/home-automation/tools/bolt/pkg/compose" 7 | "github.com/jakewright/home-automation/tools/bolt/pkg/service" 8 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 9 | ) 10 | 11 | var ( 12 | runCmd = &cobra.Command{ 13 | Use: "run [foo] [bar]...", 14 | Short: "run a service", 15 | Args: cobra.MinimumNArgs(1), 16 | Run: func(cmd *cobra.Command, args []string) { 17 | build, err := cmd.Flags().GetBool("build") 18 | if err != nil { 19 | output.Fatal("Failed to parse build flag: %v", err) 20 | } 21 | 22 | services := service.Expand(args) 23 | 24 | c, err := compose.New() 25 | if err != nil { 26 | output.Fatal("Failed to init compose: %v", err) 27 | } 28 | 29 | if build { 30 | if err := c.Build(services); err != nil { 31 | output.Fatal("Failed to build: %v", err) 32 | } 33 | } 34 | 35 | if err := service.Run(c, services); err != nil { 36 | output.Fatal("Failed to run: %v", err) 37 | } 38 | }, 39 | } 40 | ) 41 | 42 | func init() { 43 | rootCmd.AddCommand(runCmd) 44 | runCmd.Flags().BoolP("build", "b", false, "rebuild the service before running") 45 | } 46 | -------------------------------------------------------------------------------- /tools/bolt/cmd/stop.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/jakewright/home-automation/tools/bolt/pkg/compose" 7 | "github.com/jakewright/home-automation/tools/bolt/pkg/service" 8 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 9 | ) 10 | 11 | var ( 12 | stopCmd = &cobra.Command{ 13 | Use: "stop [foo] [bar]...", 14 | Short: "stop a service", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | c, err := compose.New() 17 | if err != nil { 18 | output.Fatal("Failed to init compose: %v", err) 19 | } 20 | 21 | all, err := cmd.Flags().GetBool("all") 22 | if err != nil { 23 | output.Fatal("Failed to parse all flag: %v", err) 24 | } 25 | 26 | if all { 27 | if err := c.StopAll(); err != nil { 28 | output.Fatal("Failed to stop services: %v", err) 29 | } 30 | 31 | return 32 | } 33 | 34 | if len(args) == 0 { 35 | return 36 | } 37 | 38 | services := service.Expand(args) 39 | 40 | if err := c.Stop(services); err != nil { 41 | output.Fatal("Failed to stop services: %v", err) 42 | } 43 | }, 44 | } 45 | ) 46 | 47 | func init() { 48 | rootCmd.AddCommand(stopCmd) 49 | stopCmd.Flags().Bool("all", false, "stop all services") 50 | } 51 | -------------------------------------------------------------------------------- /tools/bolt/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "service": "mysql", 4 | "engine": "mysql", 5 | "username": "root", 6 | "password": "secret", 7 | "adminService": "adminer", 8 | "adminServicePath": "/?server=mysql&username=root&db=home_automation" 9 | }, 10 | "projectName": "home-automation", 11 | "dockerComposeFilePath": "docker-compose.yml", 12 | "groups": { 13 | "core": ["api-gateway", "device-registry", "redis", "mysql"], 14 | "log": ["filebeat", "logstash", "log"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tools/bolt/dockerfiles/go.dockerfile.tmpl: -------------------------------------------------------------------------------- 1 | FROM golang:{{ .GoVersion }}-alpine 2 | 3 | # Alpine doesn't have git but go get needs it 4 | RUN apk add --no-cache git 5 | RUN go get github.com/jakewright/compile-daemon 6 | 7 | EXPOSE 80 8 | 9 | WORKDIR /app 10 | COPY . . 11 | 12 | RUN go get -v -t -d ./... 13 | 14 | # Must use exec form so that compile-daemon receives signals. The graceful-kill option then forwards them to the go binary. 15 | CMD ["compile-daemon", "-build=go install ./{{ .Service }}", "-command=/go/bin/{{ .Service }}", "-directories={{ .Service }},libraries/go", "-log-prefix=false", "-log-prefix=false", "-graceful-kill=true", "-graceful-timeout=10"] 16 | -------------------------------------------------------------------------------- /tools/bolt/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/jakewright/home-automation/tools/bolt/cmd" 7 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 8 | ) 9 | 10 | // BuildDirectory is injected at compile time 11 | var BuildDirectory string 12 | 13 | func main() { 14 | if cwd, err := os.Getwd(); err != nil { 15 | output.Fatal("Failed to get pwd: %v", err) 16 | } else if cwd != BuildDirectory { 17 | output.Fatal("Must be run from home-automation root: %s\n", BuildDirectory) 18 | } 19 | 20 | cmd.Execute() 21 | } 22 | -------------------------------------------------------------------------------- /tools/bolt/pkg/compose/file.go: -------------------------------------------------------------------------------- 1 | package compose 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "gopkg.in/yaml.v2" 8 | ) 9 | 10 | type composeFile struct { 11 | Version string `yaml:"version"` 12 | Services map[string]*composeService `yaml:"services"` 13 | Networks map[string]interface{} `yaml:"networks"` 14 | } 15 | 16 | type composeService struct { 17 | Image string `yaml:"image"` 18 | Ports []string `yaml:"ports"` 19 | } 20 | 21 | func parse(filename string) (*composeFile, error) { 22 | b, err := ioutil.ReadFile(filename) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to read docker-compose file: %w", err) 25 | } 26 | 27 | f := &composeFile{} 28 | if err := yaml.Unmarshal(b, f); err != nil { 29 | return nil, fmt.Errorf("failed to unmarshal docker-compose composeFile: %w", err) 30 | } 31 | 32 | return f, nil 33 | } 34 | -------------------------------------------------------------------------------- /tools/bolt/pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | ) 8 | 9 | // Config holds options for the tool 10 | type Config struct { 11 | Database Database `json:"database"` 12 | ProjectName string `json:"projectName"` 13 | DockerComposeFilePath string `json:"dockerComposeFilePath"` 14 | Groups map[string][]string `json:"groups"` 15 | } 16 | 17 | // Database holds config for the database service 18 | type Database struct { 19 | Service string `json:"service"` 20 | AdminService string `json:"adminService"` 21 | AdminServicePath string `json:"adminServicePath"` 22 | Engine string `json:"engine"` 23 | Username string `json:"username"` 24 | Password string `json:"password"` 25 | } 26 | 27 | var c = &Config{} 28 | 29 | // Init reads the config file and loads it into a global variable 30 | func Init() error { 31 | b, err := ioutil.ReadFile("./tools/bolt/config.json") 32 | if err != nil { 33 | return fmt.Errorf("failed to read config.json: %w", err) 34 | } 35 | 36 | if err := json.Unmarshal(b, c); err != nil { 37 | return fmt.Errorf("failed to unmarshal config: %w", err) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | // Get returns the initialised config struct 44 | func Get() *Config { 45 | if c == nil { 46 | panic("config not loaded") 47 | } 48 | 49 | return c 50 | } 51 | -------------------------------------------------------------------------------- /tools/bolt/pkg/docker/docker.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/jakewright/home-automation/libraries/go/exe" 8 | ) 9 | 10 | // IsContainerRunning returns whether the container with 11 | // the given ID is currently in a running state. 12 | func IsContainerRunning(id string) (bool, error) { 13 | if id == "" { 14 | return false, fmt.Errorf("id is empty") 15 | } 16 | 17 | result := exe.Command("docker", "inspect", "-f", "{{.State.Running}}", id).Run() 18 | if result.Err != nil { 19 | return false, fmt.Errorf("failed to run docker inspect: %w", result.Err) 20 | } 21 | 22 | b, err := strconv.ParseBool(result.Stdout) 23 | if err != nil { 24 | return false, fmt.Errorf("failed to parse docker inspect output: %w", err) 25 | } 26 | 27 | return b, nil 28 | } 29 | -------------------------------------------------------------------------------- /tools/bolt/pkg/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/jakewright/home-automation/tools/bolt/pkg/compose" 8 | "github.com/jakewright/home-automation/tools/bolt/pkg/config" 9 | ) 10 | 11 | // Expand turns a list of arguments into a 12 | // set of services by expanding the groups. 13 | func Expand(args []string) []string { 14 | var services []string 15 | for _, s := range args { 16 | services = append(services, expandService(s)...) 17 | } 18 | return services 19 | } 20 | 21 | // expandService returns the set of services 22 | // if s is a group name otherwise s 23 | func expandService(s string) []string { 24 | for groupName, services := range config.Get().Groups { 25 | if s == groupName { 26 | return services 27 | } 28 | } 29 | 30 | // To allow the user to specify the directory of the service instead of 31 | // just the service name, strip the potential prefixes off the string. 32 | s = strings.TrimPrefix(s, "service/") 33 | s = strings.TrimPrefix(s, "./service/") 34 | 35 | return []string{s} 36 | } 37 | 38 | // Run runs a set of services using the given Compose 39 | func Run(c *compose.Compose, services []string) error { 40 | for _, s := range services { 41 | if err := c.Run(s); err != nil { 42 | return fmt.Errorf("failed to run service: %w", err) 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /tools/deploy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/jakewright/home-automation/tools/deploy/cmd" 7 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 8 | ) 9 | 10 | // BuildDirectory is injected at compile time 11 | var BuildDirectory string 12 | 13 | func main() { 14 | if cwd, err := os.Getwd(); err != nil { 15 | output.Fatal("Failed to get pwd: %v", err) 16 | } else if cwd != BuildDirectory { 17 | output.Fatal("Must be run from home-automation root: %s\n", BuildDirectory) 18 | } 19 | 20 | cmd.Execute() 21 | } 22 | -------------------------------------------------------------------------------- /tools/deploy/pkg/build/build.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/oops" 5 | "github.com/jakewright/home-automation/tools/deploy/pkg/config" 6 | "github.com/jakewright/home-automation/tools/libraries/env" 7 | ) 8 | 9 | // Release represents something that can be deployed 10 | type Release struct { 11 | Cmd string 12 | Env env.Environment 13 | Revision string 14 | ShortHash string 15 | } 16 | 17 | // Machine is the interface implemented by targets that have an architecture 18 | type Machine interface { 19 | Architecture() string 20 | } 21 | 22 | // LocalBuilder prepares a release 23 | type LocalBuilder interface { 24 | Build(revision, workingDir string) (*Release, error) 25 | } 26 | 27 | // ChooseLocal returns a builder based on the service and target 28 | func ChooseLocal(service *config.Service, target Machine) (LocalBuilder, error) { 29 | switch service.Language() { 30 | case config.LangGo: 31 | return &GoBuilder{ 32 | Service: service, 33 | Target: target, 34 | }, nil 35 | } 36 | 37 | return nil, oops.BadRequest("no suitable builder") 38 | } 39 | -------------------------------------------------------------------------------- /tools/deploy/pkg/deployer/deploy.go: -------------------------------------------------------------------------------- 1 | package deployer 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jakewright/home-automation/tools/deploy/pkg/config" 7 | "github.com/jakewright/home-automation/tools/deploy/pkg/deployer/kubernetes" 8 | "github.com/jakewright/home-automation/tools/deploy/pkg/deployer/systemd" 9 | ) 10 | 11 | // Deployer deploys services 12 | type Deployer interface { 13 | Revision() (string, error) 14 | Deploy(revision string) error 15 | } 16 | 17 | // Choose returns an appropriate deployer for the service and target 18 | func Choose(service *config.Service, target *config.Target) (Deployer, error) { 19 | switch target.System() { 20 | case config.SysSystemd: 21 | return &systemd.Systemd{ 22 | Service: service, 23 | Target: target, 24 | }, nil 25 | case config.SysKubernetes: 26 | return &kubernetes.Kubernetes{ 27 | Service: service, 28 | Target: target, 29 | }, nil 30 | } 31 | 32 | return nil, fmt.Errorf("unsupported system %q", target.System()) 33 | } 34 | -------------------------------------------------------------------------------- /tools/deploy/pkg/utils/scp.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/jakewright/home-automation/libraries/go/exe" 9 | "github.com/jakewright/home-automation/libraries/go/oops" 10 | "github.com/jakewright/home-automation/tools/deploy/pkg/output" 11 | ) 12 | 13 | // SCP initiates an scp transfer from src to dst 14 | func SCP(src, username, host, dst string) error { 15 | var args []string 16 | 17 | if fi, err := os.Stat(src); err != nil { 18 | return oops.WithMessage(err, "failed to stat src") 19 | } else if fi.IsDir() { 20 | args = append(args, "-r") 21 | } 22 | 23 | args = append(args, src, fmt.Sprintf("%s@%s:%s", username, host, dst)) 24 | 25 | output.Debug("scp %s", strings.Join(args, " ")) 26 | 27 | if err := exe.Command("scp", args...).Run().Err; err != nil { 28 | return oops.WithMessage(err, "failed to scp file") 29 | } 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /tools/devicegen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func main() { 8 | if len(os.Args) < 2 { 9 | println("usage: devicegen file.json") 10 | os.Exit(1) 11 | } 12 | 13 | path := os.Args[1] 14 | generate(path) 15 | } 16 | -------------------------------------------------------------------------------- /tools/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This script should be saved in a git repo as a hook file, e.g. .git/hooks/pre-receive. 4 | # It looks for scripts in the .git/hooks/pre-receive.d directory and executes them in order, 5 | # passing along stdin. If any script exits with a non-zero status, this script exits. 6 | # https://gist.github.com/mjackson/7e602a7aa357cfe37dadcc016710931b 7 | 8 | if [ -f "$HOME/.bash_profile" ]; then 9 | source "$HOME/.bash_profile" 10 | fi 11 | 12 | script_dir=$(dirname $0) 13 | hook_name=$(basename $0) 14 | 15 | hook_dir="$script_dir/$hook_name.d" 16 | 17 | if [[ -d $hook_dir ]]; then 18 | stdin=$(cat /dev/stdin) 19 | 20 | for hook in $hook_dir/*; do 21 | echo "Running $hook_name/$hook hook" 22 | echo "$stdin" | $hook "$@" 23 | 24 | exit_code=$? 25 | 26 | if [ $exit_code != 0 ]; then 27 | exit $exit_code 28 | fi 29 | done 30 | fi 31 | 32 | exit 0 -------------------------------------------------------------------------------- /tools/install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | TICK="\xE2\x9C\x94" 6 | GREEN="\033[32m" 7 | RESET="\033[0m" 8 | 9 | # Go to the root home-automation directory 10 | cd "$(dirname "$0")/.." 11 | 12 | printf "Installing tools...\n" 13 | 14 | printf "bolt " 15 | go install -ldflags "-X main.BuildDirectory=$(pwd)" ./tools/bolt 16 | printf "$GREEN$TICK$RESET\n" 17 | 18 | printf "deploy " 19 | go install -ldflags "-X main.BuildDirectory=$(pwd)" ./tools/deploy 20 | printf "$GREEN$TICK$RESET\n" 21 | 22 | printf "devicegen " 23 | go install -ldflags "-X main.BuildDirectory=$(pwd)" ./tools/devicegen 24 | printf "$GREEN$TICK$RESET\n" 25 | 26 | printf "hooks " 27 | # Trailing slash copies contents of hooks to .git/hooks 28 | cp -pR ./tools/hooks/ ./.git/hooks 29 | printf "$GREEN$TICK$RESET\n" 30 | 31 | printf "jrpc " 32 | go install ./tools/jrpc 33 | printf "$GREEN$TICK$RESET\n" 34 | -------------------------------------------------------------------------------- /tools/jrpc/README.md: -------------------------------------------------------------------------------- 1 | # JRPC 2 | 3 | JRPC generates code from `def` files to assist with JSON-based RPCs. 4 | 5 | ### Types 6 | 7 | A message field can have one of the following types. The table shows the corresponding generated types. Fields can be marked as repeated by prepending the type with `[]`. 8 | 9 | | JRPC type | Golang type | 10 | | ----------- | ------------- | 11 | | `bool` | `bool` | 12 | | `string` | `string` | 13 | | `int8` | `int8` | 14 | | `int32` | `int32` | 15 | | `int64` | `int64` | 16 | | `uint8` | `byte` | 17 | | `uint32` | `uint32` | 18 | | `uint64` | `uint64` | 19 | | `float32` | `float32` | 20 | | `float64` | `float64` | 21 | | `bytes` | `[]byte` | 22 | | `time` | `time.Time` | 23 | | `any` | `interface{}` | 24 | | `map[x]y` | `map[x]y` | 25 | | `rgb` | `util.RGB` | 26 | 27 | ### Field options 28 | 29 | Message fields can take various options which are used to generate validation functions. The router code (`template_router.go`) automatically calls the validation functions in the generated handlers. 30 | 31 | **`required`** If set, the value in the incoming JSON must be set. This holds for repeated fields as well, i.e. the field must be set (but could be an empty array). 32 | 33 | **`min`** Can be used on numeric fields to enforce a minimum allowed value. 34 | 35 | **`max`** Can be used on numeric fields to enforce a maximum allowed value. 36 | -------------------------------------------------------------------------------- /tools/jrpc/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/jakewright/home-automation/libraries/go/svcdef" 7 | ) 8 | 9 | func main() { 10 | if len(os.Args) < 2 { 11 | println("usage: jrpc file.def") 12 | os.Exit(1) 13 | } 14 | 15 | defPath := os.Args[1] 16 | 17 | f, err := svcdef.Parse(defPath) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | if err := generate(defPath, f); err != nil { 23 | panic(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tools/jrpc/types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const ( 4 | typeAny = "any" 5 | typeBool = "bool" 6 | typeString = "string" 7 | typeInt8 = "int8" 8 | typeInt32 = "int32" 9 | typeInt64 = "int64" 10 | typeUint8 = "uint8" 11 | typeUint32 = "uint32" 12 | typeUint64 = "uint64" 13 | typeFloat32 = "float32" 14 | typeFloat64 = "float64" 15 | typeBytes = "bytes" 16 | typeTime = "time" 17 | typeRGB = "rgb" 18 | ) 19 | -------------------------------------------------------------------------------- /tools/libraries/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | ) 8 | 9 | var dir string 10 | 11 | // Init must be called before Dir() can be used 12 | func Init(tool string) error { 13 | osCacheDir, err := os.UserCacheDir() 14 | if err != nil { 15 | return fmt.Errorf("failed to get user cache dir: %w", err) 16 | } 17 | 18 | dir = path.Join(osCacheDir, "home-automation", tool) 19 | if err := os.MkdirAll(dir, os.ModePerm); err != nil { 20 | return fmt.Errorf("failed to create %s: %w", dir, err) 21 | } 22 | 23 | return nil 24 | } 25 | 26 | // Dir returns the directory that can be used 27 | // as a temporary working directory by the tool. 28 | func Dir() string { 29 | if dir == "" { 30 | panic(fmt.Errorf("function Dir() called before cache.Init()")) 31 | } 32 | 33 | return dir 34 | } 35 | -------------------------------------------------------------------------------- /tools/lint/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true 5 | }, 6 | "extends": [ 7 | "airbnb-base", 8 | "plugin:vue/recommended" 9 | ], 10 | "rules": { 11 | "import/no-unresolved": "off", 12 | "no-restricted-syntax": [ 13 | "error", 14 | "ForInStatement", 15 | "LabeledStatement", 16 | "WithStatement" 17 | ], 18 | "no-prototype-builtins": "off" 19 | }, 20 | "parserOptions": { 21 | "parser": "babel-eslint" 22 | } 23 | } -------------------------------------------------------------------------------- /tools/lint/go.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.11 2 | RUN go get -u golang.org/x/lint/golint 3 | RUN curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 4 | 5 | WORKDIR /go/src/home-automation 6 | 7 | COPY tools/lint/go_fmt.sh / 8 | RUN chmod +x /go_fmt.sh 9 | 10 | # Add lock files and install dependencies. 11 | # Hopefully this won't change much and will be cached. 12 | COPY Gopkg.* ./ 13 | RUN dep ensure -vendor-only 14 | 15 | # Add everything else. This will change a lot. 16 | # COPY . . 17 | 18 | CMD ["/go_fmt.sh"] -------------------------------------------------------------------------------- /tools/lint/go_fmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Abort if anything fails 4 | set -e 5 | 6 | printf "Formatting code..." 7 | go fmt $(go list ./... | grep -v /vendor/) 8 | 9 | printf "\nRunning golint..." 10 | golint $(go list ./... | grep -v /vendor/) 11 | 12 | printf "\nRunning go vet..." 13 | go vet $(go list ./... | grep -v /vendor/) -------------------------------------------------------------------------------- /tools/lint/js.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.14 2 | WORKDIR /usr/src 3 | 4 | RUN npm i -g prettier babel-eslint eslint eslint-plugin-vue eslint-plugin-import eslint-config-airbnb-base 5 | 6 | COPY tools/lint/js_fmt.sh ./ 7 | RUN chmod +x js_fmt.sh 8 | 9 | COPY ./tools/lint/.eslintrc ./ 10 | 11 | # WORKDIR /usr/src/home-automation/web.client 12 | # COPY ./web.client/package*.json ./ 13 | # RUN npm install 14 | 15 | COPY . ./home-automation 16 | 17 | CMD ["/usr/src/js_fmt.sh"] 18 | -------------------------------------------------------------------------------- /tools/lint/js_fmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | printf "Running Prettier..." 4 | prettier --write **/*.js 5 | 6 | printf "\nRunning eslint..." 7 | eslint --ext .vue --fix ./ -------------------------------------------------------------------------------- /tools/lint/lint.sh: -------------------------------------------------------------------------------- 1 | # Abort if anything fails 2 | set -e 3 | 4 | if [ "$1" = "" ] || [ "$1" = "go" ] 5 | then 6 | docker build --file ./tools/lint/go.dockerfile --tag home-automation-go-fmt --quiet . 7 | docker run --rm -t \ 8 | -v "$PWD":/go/src/home-automation \ 9 | home-automation-go-fmt 10 | fi 11 | 12 | if [ "$1" = "" ] || [ "$1" = "javascript" ] || [ "$1" = "js" ] 13 | then 14 | docker build --file ./tools/lint/js.dockerfile --tag home-automation-js-fmt --quiet . 15 | docker run --rm -t \ 16 | -v "$PWD":/usr/src/home-automation \ 17 | home-automation-js-fmt 18 | fi 19 | -------------------------------------------------------------------------------- /tools/templates/service/README.md.tmpl: -------------------------------------------------------------------------------- 1 | # {{service_name_kebab}} 2 | -------------------------------------------------------------------------------- /tools/templates/service/main.go.tmpl: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jakewright/home-automation/libraries/go/bootstrap" 5 | "github.com/jakewright/home-automation/services/{{service_name_kebab}}/routes" 6 | ) 7 | 8 | //go:generate jrpc {{service_name_snake}}.def 9 | 10 | func main() { 11 | conf := struct{}{} 12 | 13 | svc := bootstrap.Init(&bootstrap.Opts{ 14 | ServiceName: "{{service_name_kebab}}", 15 | Config: &conf, 16 | }) 17 | 18 | routes.Register(svc, &routes.Controller{}) 19 | 20 | svc.Run() 21 | } 22 | -------------------------------------------------------------------------------- /tools/templates/service/routes/handler.go.tmpl: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | type Controller struct {} 4 | -------------------------------------------------------------------------------- /tools/templates/service/{{service_name_snake}}.def.tmpl: -------------------------------------------------------------------------------- 1 | service {{service_name_pascal}} { 2 | path = "{{service_name_kebab}}" 3 | } 4 | -------------------------------------------------------------------------------- /web.client/.env: -------------------------------------------------------------------------------- 1 | VUE_APP_API_GATEWAY=http://192.168.1.100:7005 2 | VUE_APP_EVENT_BUS=ws://192.168.1.100:7004 -------------------------------------------------------------------------------- /web.client/.env.development: -------------------------------------------------------------------------------- 1 | VUE_APP_API_GATEWAY=http://localhost:7005 2 | VUE_APP_EVENT_BUS=ws://localhost:7004 -------------------------------------------------------------------------------- /web.client/.eslintignore: -------------------------------------------------------------------------------- 1 | ../libraries/* 2 | -------------------------------------------------------------------------------- /web.client/README.md: -------------------------------------------------------------------------------- 1 | # Web client 2 | 3 | The web client is a [Vue.js](https://vuejs.org) project. This is the main interface to the home automation system. 4 | 5 | ## Setup 6 | 7 | While it's possible to do all of this using Docker, it's not worth the hassle. A useful addition could be a Docker container that is able to execute all of the npm and Vue-related commands, but the current recommendation is to install the tools locally. 8 | 9 | First, install node using `brew`. 10 | 11 | ```sh 12 | brew update && brew install node 13 | ``` 14 | 15 | If node is already installed, make sure it is up-to-date. 16 | 17 | ```sh 18 | brew update && brew upgrade node 19 | ``` 20 | 21 | Install or update npm 22 | 23 | ``` 24 | npm install -g npm 25 | ``` 26 | 27 | Manage the project using the [Vue CLI](https://github.com/vuejs/vue-cli). Version 4.5 of the CLI is required, and this should be installed globally on your local machine. 28 | 29 | ```sh 30 | npm install -g @vue/cli 31 | ``` 32 | 33 | ## Running 34 | 35 | Run using `docker-compose`. The service is listed in the main `docker-compose.yml` file. The `node_modules` folder is baked into the image, so if any changes are made to `package.json` or `package-lock.json`, the image should be rebuilt. The `docker-compose.yml` file defines a volume at the `node_modules` location to stop local node modules from being mounted in the container at run-time. 36 | -------------------------------------------------------------------------------- /web.client/aliases.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | function resolveSrc(_path) { 4 | return path.join(__dirname, _path); 5 | } 6 | 7 | const aliases = { 8 | "@design": "src/design/index.scss", 9 | "@variables": "src/design/variables/index.scss" 10 | }; 11 | 12 | module.exports = { 13 | webpack: {}, 14 | jest: {} 15 | }; 16 | 17 | for (const alias in aliases) { 18 | module.exports.webpack[alias] = resolveSrc(aliases[alias]); 19 | module.exports.jest[`^${alias}/(.*)$`] = `/${aliases[alias]}/$1`; 20 | } 21 | -------------------------------------------------------------------------------- /web.client/darn: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This should be run from the web.client directory 4 | 5 | docker \ 6 | run --rm -it \ 7 | --volume "$PWD":/usr/src/app \ 8 | --workdir /usr/src/app \ 9 | node:15 npm "$@" 10 | -------------------------------------------------------------------------------- /web.client/dev.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:15 2 | 3 | # Add the libraries 4 | RUN mkdir -p /usr/src/libraries/javascript 5 | WORKDIR /usr/src/libraries/javascript 6 | COPY ./libraries/javascript . 7 | RUN npm install 8 | 9 | # Create app directory 10 | RUN mkdir -p /usr/src/app 11 | WORKDIR /usr/src/app 12 | 13 | # Install app dependencies 14 | RUN npm install -g @vue/cli@4.5.8 15 | COPY ./web.client/package.json . 16 | COPY ./web.client/package-lock.json . 17 | RUN npm install 18 | 19 | # Move one level up so node_modules is not overwritten by a mounted directory 20 | RUN mv node_modules /usr/src/node_modules 21 | 22 | # Expose ports for web access and debugging 23 | EXPOSE 8080 9229 24 | 25 | CMD [ "npm", "run", "serve" ] 26 | -------------------------------------------------------------------------------- /web.client/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | 5 | root /usr/share/nginx/html; 6 | 7 | # Add index.php to the list if you are using PHP 8 | index index.html index.htm index.nginx-debian.html; 9 | 10 | server_name _; 11 | 12 | location / { 13 | # First attempt to serve request as file, then 14 | # as directory, then fall back to displaying a 404. 15 | try_files $uri $uri/ /index.html; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web.client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "home-automation-vue-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "./node_modules/.bin/vue-cli-service serve --open", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.18.0", 12 | "color-circle": "^1.0.0", 13 | "core-js": "^3.6.5", 14 | "lodash": "^4.17.11", 15 | "vue": "^3.0.2", 16 | "vue-class-component": "^8.0.0-0", 17 | "vue-router": "^4.0.0-rc.1", 18 | "vuex": "^4.0.0-beta.4" 19 | }, 20 | "devDependencies": { 21 | "@vue/cli-plugin-babel": "~4.5.8", 22 | "@vue/cli-plugin-typescript": "^4.5.8", 23 | "@vue/cli-service": "~4.5.8", 24 | "@vue/compiler-sfc": "^3.0.2", 25 | "node-sass": "^4.9.0", 26 | "sass-loader": "^7.0.1", 27 | "typescript": "~3.9.3" 28 | }, 29 | "babel": { 30 | "presets": [ 31 | "@vue/app" 32 | ] 33 | }, 34 | "postcss": { 35 | "plugins": { 36 | "autoprefixer": {} 37 | } 38 | }, 39 | "browserslist": [ 40 | "> 1%", 41 | "last 2 versions", 42 | "not ie <= 8" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /web.client/prod.dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | 3 | # Add the libraries 4 | RUN mkdir -p /usr/src/libraries/javascript 5 | WORKDIR /usr/src/libraries/javascript 6 | COPY ./libraries/javascript . 7 | RUN npm install 8 | 9 | # Create app directory 10 | RUN mkdir -p /usr/src/app 11 | WORKDIR /usr/src/app 12 | 13 | # Install app dependencies 14 | RUN npm install -g @vue/cli@4.5.8 15 | COPY ./web.client/package.json . 16 | COPY ./web.client/package-lock.json . 17 | RUN npm install 18 | 19 | # Copy source code 20 | COPY ./web.client/ . 21 | 22 | # Build the app 23 | RUN npm run build 24 | 25 | FROM nginx 26 | COPY --from=0 /usr/src/app/nginx.conf /etc/nginx/conf.d/default.conf 27 | COPY --from=0 /usr/src/app/dist /usr/share/nginx/html 28 | -------------------------------------------------------------------------------- /web.client/public/apple-touch-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakewright/home-automation/34c5bf1cd2ff6753e79ee8dae39df51716aec309/web.client/public/apple-touch-180x180.png -------------------------------------------------------------------------------- /web.client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakewright/home-automation/34c5bf1cd2ff6753e79ee8dae39df51716aec309/web.client/public/favicon.ico -------------------------------------------------------------------------------- /web.client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Home Automation 12 | 13 | 14 | 15 | 16 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /web.client/src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 17 | 18 | 22 | -------------------------------------------------------------------------------- /web.client/src/api/EventConsumer.js: -------------------------------------------------------------------------------- 1 | import { apiToDevice } from "../domain/marshalling"; 2 | 3 | export default class EventConsumer { 4 | constructor(url, store) { 5 | this.url = url; 6 | this.store = store; 7 | } 8 | 9 | listen() { 10 | this.socket = new WebSocket(this.url); 11 | this.socket.onmessage = event => { 12 | try { 13 | const data = JSON.parse(event.data); 14 | const [eventType] = data.channel.split("."); 15 | 16 | switch (eventType) { 17 | case "device-state-changed": 18 | this.handleStateChangedEvent(data.message); 19 | } 20 | } catch (err) { 21 | // Ignore events that are not JSON encoded 22 | } 23 | }; 24 | } 25 | 26 | handleStateChangedEvent(msg) { 27 | this.store.commit("setDevice", apiToDevice(msg)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /web.client/src/api/index.js: -------------------------------------------------------------------------------- 1 | import http from "../../../libraries/javascript/http"; 2 | import { apiToDevice, apiToRoom, apiToRooms } from "../domain/marshalling"; 3 | 4 | /** 5 | * Fetch device information from the device's controller 6 | * 7 | * @param {DeviceHeader} deviceHeader Metadata about the device 8 | * @return {Device} 9 | */ 10 | const fetchDevice = async deviceHeader => { 11 | const url = `${deviceHeader.controllerName}/device/${ 12 | deviceHeader.identifier 13 | }`; 14 | const rsp = await http.get(url); 15 | return apiToDevice(rsp); 16 | }; 17 | 18 | /** 19 | * Update a single property on a device 20 | * 21 | * @param {Object} deviceHeader Object containing device identifier and controller name 22 | * @param {Object} properties A map of property names to their new values. Properties that are omitted will not be updated. 23 | * 24 | * @return {Device} The updated Device object 25 | */ 26 | const updateDevice = async ({ identifier, controllerName }, properties) => { 27 | const url = `${controllerName}/device/${identifier}`; 28 | const rsp = await http.patch(url, properties); 29 | return apiToDevice(rsp); 30 | }; 31 | 32 | /** 33 | * Fetch all rooms 34 | * @returns {Array.} 35 | */ 36 | const fetchRooms = async () => { 37 | const rsp = await http.get("device-registry/rooms"); 38 | return apiToRooms(rsp.rooms); 39 | }; 40 | 41 | /** 42 | * Fetch a single room by ID 43 | * 44 | * @param identifier 45 | * @returns {Room} 46 | */ 47 | const fetchRoom = async identifier => { 48 | const rsp = await http.get(`device-registry/room/${identifier}`); 49 | return apiToRoom(rsp); 50 | }; 51 | 52 | export default { fetchDevice, updateDevice, fetchRooms, fetchRoom }; 53 | -------------------------------------------------------------------------------- /web.client/src/api/utils.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | /** 4 | * Recursively convert an object's property names to camel case using Lodash functions. 5 | */ 6 | const toCamelCase = input => { 7 | if (_.isArray(input)) { 8 | return input.map(toCamelCase); 9 | } 10 | 11 | if (!_.isPlainObject(input)) { 12 | return input; 13 | } 14 | 15 | const result = {}; 16 | 17 | _.forEach(input, (value, key) => { 18 | const newKey = _.camelCase(key); 19 | result[newKey] = toCamelCase(value); 20 | }); 21 | 22 | return result; 23 | }; 24 | 25 | export { toCamelCase }; 26 | -------------------------------------------------------------------------------- /web.client/src/components/base/ColorCircle.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 46 | 47 | 52 | -------------------------------------------------------------------------------- /web.client/src/components/base/Tile.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | -------------------------------------------------------------------------------- /web.client/src/components/devices/controls/NumberControl.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 37 | -------------------------------------------------------------------------------- /web.client/src/components/devices/controls/RgbControl.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 39 | 40 | 50 | -------------------------------------------------------------------------------- /web.client/src/components/devices/controls/SelectControl.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 48 | -------------------------------------------------------------------------------- /web.client/src/components/devices/controls/SliderControl.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 75 | -------------------------------------------------------------------------------- /web.client/src/components/errors/BaseError.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /web.client/src/components/errors/ErrorList.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 38 | 39 | 47 | 59 | -------------------------------------------------------------------------------- /web.client/src/components/pages/RawJsonViewer.vue: -------------------------------------------------------------------------------- 1 |