├── .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 |
2 |
3 |
4 |
5 |
6 |
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 |
2 |
17 |
18 |
19 |
46 |
47 |
52 |
--------------------------------------------------------------------------------
/web.client/src/components/base/Tile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
16 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/web.client/src/components/devices/controls/NumberControl.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
37 |
--------------------------------------------------------------------------------
/web.client/src/components/devices/controls/RgbControl.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
39 |
40 |
50 |
--------------------------------------------------------------------------------
/web.client/src/components/devices/controls/SelectControl.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
21 |
22 |
23 |
24 |
48 |
--------------------------------------------------------------------------------
/web.client/src/components/devices/controls/SliderControl.vue:
--------------------------------------------------------------------------------
1 |
2 |
25 |
26 |
27 |
75 |
--------------------------------------------------------------------------------
/web.client/src/components/errors/BaseError.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/web.client/src/components/errors/ErrorList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 | {{ err.message }}
10 |
11 |
12 |
13 |
14 |
15 |
38 |
39 |
47 |
59 |
--------------------------------------------------------------------------------
/web.client/src/components/pages/RawJsonViewer.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
11 |
16 |
--------------------------------------------------------------------------------
/web.client/src/components/pages/RoomSelector.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 | Rooms
7 |
8 |
9 |
13 | -
18 |
19 | {{ room.name }}
20 |
21 |
22 |
23 |
24 |
25 |
Failed to fetch rooms: {{ fetchError }}
26 |
27 |
28 |
29 |
30 |
31 |
65 |
--------------------------------------------------------------------------------
/web.client/src/design/base/buttons.scss:
--------------------------------------------------------------------------------
1 | //button {
2 | // background: none;
3 | // border: none;
4 | // color: $color-white;
5 | // font-size: 2.1rem;
6 | // cursor: pointer;
7 | //}
8 |
--------------------------------------------------------------------------------
/web.client/src/design/base/grid.scss:
--------------------------------------------------------------------------------
1 | //.grid-container {
2 | // display: grid;
3 | // grid-template-columns: repeat(12, 1fr);
4 | //
5 | // @media screen and (max-width: $screen-md-min) {
6 | // display: block;
7 | //
8 | // // When the page is mobile-sized, make each column max width and browser-height
9 | // .side-column {
10 | // position: fixed;
11 | // top: 0;
12 | // left: 0;
13 | // right: 0;
14 | // height: 100vh;
15 | // overflow-y: scroll;
16 | // -webkit-overflow-scrolling: touch;
17 | // }
18 | // }
19 | //
20 | // &.full-height {
21 | // height: 100vh;
22 | // }
23 | //}
24 | //
25 | //.grid-item {
26 | // background-color: $color-gray;
27 | // grid-column: span 6;
28 | //}
29 |
--------------------------------------------------------------------------------
/web.client/src/design/base/input.scss:
--------------------------------------------------------------------------------
1 | // Styling Cross-Browser Compatible Range Inputs with Sass
2 | // Github: https://github.com/darlanrod/input-range-sass
3 | // Author: Darlan Rod https://github.com/darlanrod
4 | // Version 1.4.1
5 | // MIT License
6 |
7 | $track-color: $color-gray-lighter !default;
8 | $thumb-color: $color-green !default;
9 |
10 | $thumb-radius: 16px !default;
11 | $thumb-height: 32px !default;
12 | $thumb-width: 32px !default;
13 |
14 | $track-width: 100% !default;
15 | $track-height: 4px !default;
16 |
17 | $track-radius: 5px !default;
18 |
19 |
20 | @mixin track {
21 | cursor: pointer;
22 | height: $track-height;
23 | transition: all .2s ease;
24 | width: $track-width;
25 | }
26 |
27 | @mixin thumb {
28 | background: $thumb-color;
29 | border: none;
30 | border-radius: $thumb-radius;
31 | cursor: ew-resize;
32 | height: $thumb-height;
33 | width: $thumb-width;
34 | }
35 |
36 | [type='range'] {
37 | -webkit-appearance: none;
38 | margin: $thumb-height / 2 0;
39 | width: $track-width;
40 | transition: all 0.5s ease;
41 |
42 | &:focus {
43 | outline: 0;
44 |
45 | &::-webkit-slider-runnable-track {
46 | background: $track-color;
47 | }
48 | }
49 |
50 | &::-webkit-slider-runnable-track {
51 | @include track;
52 | background: $track-color;
53 | border: none;
54 | border-radius: $track-radius;
55 | }
56 |
57 | &::-webkit-slider-thumb {
58 | @include thumb;
59 | -webkit-appearance: none;
60 | margin-top: ($track-height / 2) - ($thumb-height / 2);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/web.client/src/design/base/links.scss:
--------------------------------------------------------------------------------
1 | a {
2 | color: $color-text;
3 | text-decoration: none;
4 | }
5 |
--------------------------------------------------------------------------------
/web.client/src/design/base/list.scss:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakewright/home-automation/34c5bf1cd2ff6753e79ee8dae39df51716aec309/web.client/src/design/base/list.scss
--------------------------------------------------------------------------------
/web.client/src/design/pages/home.scss:
--------------------------------------------------------------------------------
1 | #home .room-selector {
2 | width: calc(100% + #{2 * $margin-body});
3 | height: calc(52px + #{2 * $padding-element}); // Reserve the height while the rooms load
4 | margin-left: -$margin-body;
5 | overflow: hidden;
6 |
7 | &::-webkit-scrollbar {
8 | display: none;
9 | }
10 |
11 | .scroller {
12 | display: flex;
13 | overflow-x: scroll;
14 | -webkit-overflow-scrolling: touch;
15 |
16 | // Hide the scroll bar
17 | height: calc(100% + 2rem);
18 | overflow-y: hidden;
19 |
20 | a {
21 | display: flex;
22 | .box {
23 | display: flex;
24 |
25 | margin-right: $spacing-element;
26 | padding: $padding-element;
27 | width: 120px;
28 | height: 52px;
29 | border-radius: 10px;
30 |
31 | color: $color-white;
32 |
33 | .text-container {
34 | align-self: flex-end;
35 | }
36 | }
37 |
38 | &:first-child .box {
39 | margin-left: $margin-body;
40 | }
41 |
42 | &:last-child .box {
43 | margin-right: $margin-body;
44 | }
45 |
46 | &:nth-child(3n - 2) .box {
47 | background: linear-gradient(135deg, $color-purple 0%, $color-blue 100%);
48 | }
49 |
50 | &:nth-child(3n - 1) .box {
51 | background: linear-gradient(135deg, $color-red 0%, $color-yellow 100%);
52 | }
53 |
54 | &:nth-child(3n) .box {
55 | background: linear-gradient(135deg, $color-green 0%, $color-yellow 100%);
56 | }
57 | }
58 | }
59 | }
--------------------------------------------------------------------------------
/web.client/src/design/variables/breakpoints.scss:
--------------------------------------------------------------------------------
1 | // Extra small screen / phone
2 | $screen-xs-min: 480px;
3 |
4 | // Small screen / tablet
5 | $screen-sm-min: 768px;
6 |
7 | // Medium screen / desktop
8 | $screen-md-min: 992px;
9 |
10 | // Large screen / wide desktop
11 | $screen-lg-min: 1200px;
12 |
13 | // Larger screen / wider desktop
14 | $screen-xlg-min: 1600px;
15 |
--------------------------------------------------------------------------------
/web.client/src/design/variables/index.scss:
--------------------------------------------------------------------------------
1 | @import 'breakpoints.scss';
2 |
3 | $spacing-xs: 5px;
4 | $spacing-sm: 10px;
5 | $spacing-md: 20px;
6 | $spacing-lg: 30px;
7 |
8 | $margin-body: $spacing-md;
9 | $padding-element: $spacing-sm;
10 | $spacing-element: $spacing-sm;
11 |
12 | $font-size-h1: 3.6rem;
13 | $font-size-h2: 2.4rem;
14 | $font-size-body: 1.7rem;
15 | $font-size-callout: 1.4rem;
16 | $font-size-caption: 1.2rem;
17 |
18 | $font-weight-bold: 700;
19 | $font-weight-medium: 500;
20 | $font-weight-regular: 400;
21 |
22 | $border-radius: 10px;
23 |
24 | $color-blue: #2D9CDB;
25 | $color-purple: #9B51E0;
26 | $color-green: #6FCF97;
27 | $color-yellow: #F2C94C;
28 | $color-orange: #F2994A;
29 | $color-red: #EB5757;
30 |
31 | $color-black: #000000;
32 | $color-gray-dark: #3B3B3B;
33 | $color-gray: #696969;
34 | $color-gray-light: #AAAAAA;
35 | $color-gray-lighter: #EEEEEE;
36 | $color-white: #FFFFFF;
37 |
38 | $color-background: $color-white;
39 | $color-background-secondary: $color-gray-lighter;
40 | $color-text: $color-gray-dark;
41 | $color-text-fade: $color-gray-light;
42 | $color-text-inverted: $color-white;
43 | $color-highlight-primary: $color-green;
44 |
--------------------------------------------------------------------------------
/web.client/src/domain/Device.js:
--------------------------------------------------------------------------------
1 | export default class Device {
2 | constructor(identifier, name, deviceType, controllerName, state) {
3 | this.identifier = identifier;
4 | this.name = name;
5 | this.deviceType = deviceType;
6 | this.controllerName = controllerName;
7 | this.state = state;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/web.client/src/domain/DeviceHeader.js:
--------------------------------------------------------------------------------
1 | export default class DeviceHeader {
2 | constructor(identifier, name, type, kind, controllerName) {
3 | this.identifier = identifier;
4 | this.name = name;
5 | this.type = type;
6 | this.kind = kind;
7 | this.controllerName = controllerName;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/web.client/src/domain/Room.js:
--------------------------------------------------------------------------------
1 | export default class Room {
2 | /**
3 | * @param {string} identifier
4 | * @param {string} name
5 | * @param {Array.} deviceHeaders
6 | */
7 | constructor(identifier, name, deviceHeaders) {
8 | this.identifier = identifier;
9 | this.name = name;
10 | this.deviceHeaders = deviceHeaders;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/web.client/src/domain/marshalling.js:
--------------------------------------------------------------------------------
1 | import Device from "./Device";
2 | import _ from "lodash";
3 | import Room from "./Room";
4 | import DeviceHeader from "./DeviceHeader";
5 |
6 | /**
7 | * Converts a JSON API response into a domain object
8 | *
9 | * @param {Object} rsp
10 | * @returns {Device}
11 | */
12 | const apiToDevice = rsp => {
13 | return new Device(
14 | rsp.identifier,
15 | rsp.name,
16 | rsp.type,
17 | rsp.controllerName,
18 | rsp.state
19 | );
20 | };
21 |
22 | /**
23 | * Converts a JSON API response into a domain object
24 | * @param {Object} rsp
25 | * @returns {Room}
26 | */
27 | const apiToRoom = rsp => {
28 | const deviceHeaders = rsp.devices.map(
29 | d => new DeviceHeader(d.id, d.name, d.type, d.kind, d.controllerName)
30 | );
31 | return new Room(rsp.id, rsp.name, deviceHeaders);
32 | };
33 |
34 | /**
35 | * Converts a JSON API response into an array of domain objects
36 | * @param {Array} rsp
37 | * @returns {Array.}
38 | */
39 | const apiToRooms = rsp => rsp.map(r => apiToRoom(r));
40 |
41 | export { apiToDevice, apiToRoom, apiToRooms };
42 |
--------------------------------------------------------------------------------
/web.client/src/main.ts:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 | import Vuex from "vuex";
3 | import { createApp } from "vue";
4 |
5 | //import { library } from "@fortawesome/fontawesome-svg-core";
6 | // import { faLightbulb, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
7 | // import { faHome as farHome, faArrowLeft as farArrowLeft } from "@fortawesome/pro-regular-svg-icons";
8 | // import { faLightbulb as falLightbulb } from "@fortawesome/pro-light-svg-icons";
9 | //import { fal } from "@fortawesome/pro-light-svg-icons";
10 | //import { far } from "@fortawesome/pro-regular-svg-icons";
11 | //import { fas } from "@fortawesome/pro-solid-svg-icons";
12 | //import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
13 |
14 | import httpClient from "../../libraries/javascript/http";
15 |
16 | import App from "./App.vue";
17 | import store from "./store";
18 | import router from "./router";
19 | import EventConsumer from "./api/EventConsumer";
20 |
21 | httpClient.setApiGateway(process.env.VUE_APP_API_GATEWAY);
22 |
23 | // library.add(faLightbulb, falLightbulb, faSpinnerThird, farArrowLeft, farHome);
24 | // library.add(fas, far, fal);
25 | // Vue.component("FontAwesomeIcon", FontAwesomeIcon);
26 |
27 | const app = createApp(App);
28 | app.use(router)
29 | app.use(store)
30 | app.mount("#app")
31 |
32 |
33 | const eventBusUrl =
34 | process.env.NODE_ENV === "production"
35 | ? "ws://192.168.1.100:7004"
36 | : "ws://localhost:7004";
37 | const eventConsumer = new EventConsumer(eventBusUrl, store);
38 | eventConsumer.listen();
39 |
--------------------------------------------------------------------------------
/web.client/src/pages/404/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Not Found
4 |
5 |
6 | Home
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/web.client/src/pages/home/RoomSelector.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ fetchError }}
4 |
5 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/web.client/src/pages/home/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Hello
4 |
Favourites
5 |
Coming soon...
6 |
7 |
Rooms
8 |
9 |
10 |
Scenes
11 |
Coming soon...
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/web.client/src/pages/room/Lights.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/web.client/src/router/index.js:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from "vue-router";
2 | import routes from "./routes";
3 |
4 | const router = createRouter({
5 | history: createWebHistory(),
6 | routes
7 | });
8 |
9 | export default router;
10 |
--------------------------------------------------------------------------------
/web.client/src/router/routes.js:
--------------------------------------------------------------------------------
1 | import Home from "../pages/home";
2 | import Room from "../pages/room";
3 | import NotFound from "../pages/404";
4 | import ColorPicker from "../components/pages/ColorPicker";
5 |
6 | export default [
7 | {
8 | path: "/",
9 | name: "home",
10 | component: Home
11 | },
12 | {
13 | path: "/room/:roomId",
14 | name: "room",
15 | component: Room,
16 | children: [
17 | {
18 | path: "device/:deviceId/rgb",
19 | name: "rgb",
20 | component: ColorPicker
21 | }
22 | ]
23 | },
24 | {
25 | path: '/:pathMatch(.*)*',
26 | name: "not-found",
27 | component: NotFound,
28 | }
29 | ];
30 |
--------------------------------------------------------------------------------
/web.client/src/shims-vue.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.vue' {
2 | import type { DefineComponent } from 'vue'
3 | const component: DefineComponent<{}, {}, any>
4 | export default component
5 | }
6 |
--------------------------------------------------------------------------------
/web.client/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { createLogger, createStore } from "vuex";
2 | import devices from "./modules/devices";
3 | import errors from "./modules/errors";
4 | import rooms from "./modules/rooms";
5 |
6 | const debug = true;
7 |
8 | const store = createStore({
9 | modules: {
10 | devices,
11 | errors,
12 | rooms
13 | },
14 | strict: debug,
15 | plugins: debug ? [createLogger()]: []
16 | });
17 |
18 | export default store
19 |
--------------------------------------------------------------------------------
/web.client/src/store/modules/devices.js:
--------------------------------------------------------------------------------
1 | import api from "../../api";
2 | import Vue from "vue";
3 |
4 | const state = {
5 | all: {}
6 | };
7 |
8 | const getters = {
9 | device: state => deviceId => state.all[deviceId]
10 | };
11 |
12 | const actions = {
13 | async fetchDevice({ commit }, deviceId) {
14 | const device = await api.fetchDevice(deviceId);
15 | commit("setDevice", device);
16 | },
17 |
18 | async updateDevice({ commit, getters }, { deviceId, properties }) {
19 | const header = getters.device(deviceId);
20 | console.log("controllerName", header.controllerName);
21 | const device = await api.updateDevice(header, properties);
22 | commit("setDevice", device);
23 | },
24 |
25 | async updateDeviceProperty({ dispatch }, { deviceId, name, value }) {
26 | await dispatch("updateDevice", { deviceId, properties: { [name]: value } });
27 | }
28 | };
29 |
30 | const mutations = {
31 | setDevice(state, device) {
32 | Vue.set(state.all, device.identifier, device);
33 | }
34 | };
35 |
36 | export default {
37 | state,
38 | getters,
39 | actions,
40 | mutations
41 | };
42 |
--------------------------------------------------------------------------------
/web.client/src/store/modules/errors.js:
--------------------------------------------------------------------------------
1 | import Vue from "vue";
2 |
3 | let id = 0;
4 |
5 | const state = {
6 | all: {}
7 | };
8 |
9 | const getters = {
10 | // Convert the map of errors into an array
11 | allErrors: state => Object.values(state.all)
12 | };
13 |
14 | const actions = {
15 | enqueueError({ commit }, err) {
16 | err.id = id++;
17 | commit("setError", err);
18 | },
19 |
20 | removeError({ commit }, id) {
21 | return commit("removeError", id);
22 | }
23 | };
24 |
25 | const mutations = {
26 | setError(state, err) {
27 | Vue.set(state.all, err.id, err);
28 | },
29 |
30 | removeError(state, id) {
31 | Vue.delete(state.all, id);
32 | }
33 | };
34 |
35 | export default {
36 | state,
37 | getters,
38 | actions,
39 | mutations
40 | };
41 |
--------------------------------------------------------------------------------
/web.client/src/store/modules/rooms.js:
--------------------------------------------------------------------------------
1 | import api from "../../api";
2 | import _ from "lodash";
3 | import Vue from "vue";
4 |
5 | const state = {
6 | all: {}
7 | };
8 |
9 | const getters = {
10 | // Convert the map of rooms into an array
11 | allRooms: state => Object.values(state.all),
12 |
13 | room: state => roomId => state.all[roomId]
14 | };
15 |
16 | const actions = {
17 | async fetchRooms({ commit }) {
18 | const rooms = await api.fetchRooms();
19 | commit("setRooms", rooms);
20 | },
21 |
22 | async fetchRoom({ commit }, roomId) {
23 | const room = await api.fetchRoom(roomId);
24 | commit("setRoom", room);
25 | }
26 | };
27 |
28 | const mutations = {
29 | setRooms(state, rooms) {
30 | state.all = _.keyBy(rooms, room => room.identifier);
31 | },
32 | setRoom(state, room) {
33 | Vue.set(state.all, room.identifier, room);
34 | }
35 | };
36 |
37 | export default {
38 | state,
39 | getters,
40 | actions,
41 | mutations
42 | };
43 |
--------------------------------------------------------------------------------
/web.client/src/templates/Base.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
14 |
15 |
16 | {{ error }}
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
65 |
--------------------------------------------------------------------------------
/web.client/src/utils/validators.js:
--------------------------------------------------------------------------------
1 | const isHexColor = value => /^#[0-9A-F]{6}$/i.test(value);
2 |
3 | export { isHexColor };
4 |
--------------------------------------------------------------------------------
/web.client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "strict": true,
6 | "allowJs": true,
7 | "jsx": "preserve",
8 | "importHelpers": true,
9 | "moduleResolution": "node",
10 | "experimentalDecorators": true,
11 | "skipLibCheck": true,
12 | "esModuleInterop": true,
13 | "allowSyntheticDefaultImports": true,
14 | "sourceMap": true,
15 | "baseUrl": ".",
16 | "types": [
17 | "webpack-env"
18 | ],
19 | "paths": {
20 | "@/*": [
21 | "src/*"
22 | ]
23 | },
24 | "lib": [
25 | "esnext",
26 | "dom",
27 | "dom.iterable",
28 | "scripthost"
29 | ]
30 | },
31 | "include": [
32 | "src/**/*.ts",
33 | "src/**/*.tsx",
34 | "src/**/*.vue",
35 | "tests/**/*.ts",
36 | "tests/**/*.tsx"
37 | ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/web.client/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | configureWebpack: {
3 | resolve: {
4 | alias: require("./aliases.config").webpack
5 | }
6 | }
7 | };
8 |
--------------------------------------------------------------------------------