217 Commits

Author SHA1 Message Date
Joel Wetzell
bd3b8a19d5 Merge pull request #47 from jwetzell/fix/module-id-required
add check to prevent empty module Id
2026-02-02 13:00:36 -06:00
Joel Wetzell
cb738a9b8e combine route and router labels 2026-02-02 12:59:50 -06:00
Joel Wetzell
1b59dbc1e5 add router to labeler 2026-02-02 12:55:23 -06:00
Joel Wetzell
c75a2327d5 add check to prevent empty module Id 2026-02-02 12:51:56 -06:00
Joel Wetzell
a17be985e6 todo 2026-02-01 20:52:58 -06:00
Joel Wetzell
78737f57af Merge pull request #46 from jwetzell/chore/upgrade-artnet
update artnet dependency
2026-01-31 19:32:12 -06:00
Joel Wetzell
720c0cd52a update artnet dependency 2026-01-31 19:29:30 -06:00
Joel Wetzell
3e1d4f07f9 Merge pull request #43 from jwetzell/dependabot/go_modules/github.com/emiago/diago-0.25.0
Bump github.com/emiago/diago from 0.24.0 to 0.25.0
2026-01-20 11:47:43 -06:00
dependabot[bot]
452e308f8f Bump github.com/emiago/diago from 0.24.0 to 0.25.0
Bumps [github.com/emiago/diago](https://github.com/emiago/diago) from 0.24.0 to 0.25.0.
- [Release notes](https://github.com/emiago/diago/releases)
- [Commits](https://github.com/emiago/diago/compare/v0.24.0...v0.25.0)

---
updated-dependencies:
- dependency-name: github.com/emiago/diago
  dependency-version: 0.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-20 17:38:15 +00:00
Joel Wetzell
d16d833520 Merge pull request #44 from jwetzell/dependabot/go_modules/github.com/emiago/sipgo-1.1.1
Bump github.com/emiago/sipgo from 1.1.0 to 1.1.1
2026-01-20 11:37:02 -06:00
Joel Wetzell
f203b32261 Merge pull request #45 from jwetzell/dependabot/go_modules/github.com/urfave/cli/v3-3.6.2
Bump github.com/urfave/cli/v3 from 3.6.1 to 3.6.2
2026-01-20 11:36:51 -06:00
dependabot[bot]
361c4f4499 Bump github.com/urfave/cli/v3 from 3.6.1 to 3.6.2
Bumps [github.com/urfave/cli/v3](https://github.com/urfave/cli) from 3.6.1 to 3.6.2.
- [Release notes](https://github.com/urfave/cli/releases)
- [Changelog](https://github.com/urfave/cli/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/urfave/cli/compare/v3.6.1...v3.6.2)

---
updated-dependencies:
- dependency-name: github.com/urfave/cli/v3
  dependency-version: 3.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 19:15:13 +00:00
dependabot[bot]
a76d12d799 Bump github.com/emiago/sipgo from 1.1.0 to 1.1.1
Bumps [github.com/emiago/sipgo](https://github.com/emiago/sipgo) from 1.1.0 to 1.1.1.
- [Release notes](https://github.com/emiago/sipgo/releases)
- [Commits](https://github.com/emiago/sipgo/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: github.com/emiago/sipgo
  dependency-version: 1.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 19:14:56 +00:00
Joel Wetzell
7d91e64ec4 add complete test coverage for string processors 2026-01-17 20:42:37 -06:00
Joel Wetzell
589c59c693 fix label for cli 2026-01-17 20:02:50 -06:00
Joel Wetzell
56c67c5445 Merge pull request #42 from jwetzell/feat/nats-message-create-subject-template
allow subject to be a template in nats.message.create
2026-01-17 19:31:53 -06:00
Joel Wetzell
13229215a6 allow subject to be a template in nats.message.create 2026-01-17 19:27:40 -06:00
Joel Wetzell
909b4d2337 add TODO 2026-01-17 19:24:21 -06:00
Joel Wetzell
3bcb9a6608 Merge pull request #41 from jwetzell/fix/dtmf-create-rune-check
return error if DTMF create results in invalid characters
2026-01-17 18:40:53 -06:00
Joel Wetzell
17dcffc09b return error if dtmf create results in invalid characters 2026-01-17 18:39:54 -06:00
Joel Wetzell
8318645eef Merge pull request #40 from jwetzell/chore/router-context-handling
move router context to run methods
2026-01-16 19:04:21 -06:00
Joel Wetzell
0c777a8874 move router context to run methods 2026-01-16 18:50:40 -06:00
Joel Wetzell
a8f9894ff0 Merge pull request #39 from jwetzell/feat/wasm-module
add WASM processor
2026-01-14 17:56:33 -06:00
Joel Wetzell
f0f74f50e5 allow specifying function name to call 2026-01-14 17:53:37 -06:00
Joel Wetzell
bb3a8b982b add script.wasm via extism plugins 2026-01-14 17:50:16 -06:00
Joel Wetzell
be9ce5a8aa update to versioned artnet library 2026-01-14 17:35:31 -06:00
Joel Wetzell
ac2cf078a4 Remove debug print statement from SIPCallServer 2026-01-14 09:38:57 -06:00
Joel Wetzell
0fc264d0e9 Merge pull request #38 from jwetzell/dependabot/go_modules/github.com/emiago/sipgo-1.0.2
Bump github.com/emiago/sipgo from 1.0.1 to 1.0.2
2026-01-14 09:02:28 -06:00
dependabot[bot]
3c99eef999 Bump github.com/emiago/sipgo from 1.0.1 to 1.0.2
Bumps [github.com/emiago/sipgo](https://github.com/emiago/sipgo) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/emiago/sipgo/releases)
- [Commits](https://github.com/emiago/sipgo/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: github.com/emiago/sipgo
  dependency-version: 1.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-14 15:01:01 +00:00
Joel Wetzell
f7c3a85649 Merge pull request #37 from jwetzell/dependabot/go_modules/github.com/emiago/diago-0.24.0
Bump github.com/emiago/diago from 0.23.1-0.20251211215055-e1d875617111 to 0.24.0
2026-01-14 08:59:30 -06:00
dependabot[bot]
76caccd9f8 Bump github.com/emiago/diago
Bumps [github.com/emiago/diago](https://github.com/emiago/diago) from 0.23.1-0.20251211215055-e1d875617111 to 0.24.0.
- [Release notes](https://github.com/emiago/diago/releases)
- [Commits](https://github.com/emiago/diago/commits/v0.24.0)

---
updated-dependencies:
- dependency-name: github.com/emiago/diago
  dependency-version: 0.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 21:31:51 +00:00
Joel Wetzell
e8c71562a6 update artnet-go 2025-12-30 11:58:48 -06:00
Joel Wetzell
42551127ba Merge pull request #36 from jwetzell/dependabot/go_modules/modernc.org/quickjs-0.17.1
Bump modernc.org/quickjs from 0.17.0 to 0.17.1
2025-12-29 16:11:25 -06:00
Joel Wetzell
a99fef9389 Merge pull request #35 from jwetzell/dependabot/github_actions/docker/setup-buildx-action-3.12.0
Bump docker/setup-buildx-action from 3.11.1 to 3.12.0
2025-12-29 16:11:16 -06:00
Joel Wetzell
e6099b6acd update cli usage in README 2025-12-29 16:09:24 -06:00
Joel Wetzell
b93d754c19 fix usage descriptions 2025-12-29 16:08:52 -06:00
Joel Wetzell
710dcf3a02 change logging flags 2025-12-29 16:07:56 -06:00
dependabot[bot]
ac83b339a6 Bump modernc.org/quickjs from 0.17.0 to 0.17.1
Bumps [modernc.org/quickjs](https://gitlab.com/cznic/quickjs) from 0.17.0 to 0.17.1.
- [Commits](https://gitlab.com/cznic/quickjs/compare/v0.17.0...v0.17.1)

---
updated-dependencies:
- dependency-name: modernc.org/quickjs
  dependency-version: 0.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-29 17:28:41 +00:00
dependabot[bot]
cb06e2dbb5 Bump docker/setup-buildx-action from 3.11.1 to 3.12.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.11.1 to 3.12.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](e468171a9d...8d2750c68a)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-29 17:28:19 +00:00
Joel Wetzell
3ef41d0026 Merge pull request #34 from jwetzell/feat/sip-response
support responding to sip dtmf/audio calls
2025-12-28 20:45:51 -06:00
Joel Wetzell
8dcb70bfee Merge pull request #33 from jwetzell/fix/route-concurrency
process routes concurrently
2025-12-28 20:44:03 -06:00
Joel Wetzell
59f00c1a32 process routes concurrently 2025-12-28 20:42:11 -06:00
Joel Wetzell
6e88d259b8 support for responding with dtmf or an audio file to both sip call types 2025-12-28 20:40:09 -06:00
Joel Wetzell
bb33974e1c start messing with controlling response to SIP calls 2025-12-28 19:29:38 -06:00
Joel Wetzell
bd2a68ff6e test a couple error router scenarios 2025-12-28 18:17:23 -06:00
Joel Wetzell
7e2d76ef3a add test for router creation and basic input output 2025-12-28 18:09:51 -06:00
Joel Wetzell
d1cec1e094 Merge pull request #32 from jwetzell/feat/json-encode
add processor to encode json bytes
2025-12-28 16:27:12 -06:00
Joel Wetzell
477d70fad0 remove last byte in json.encode because of new line 2025-12-28 16:26:11 -06:00
Joel Wetzell
70f4636522 add test for json.encode 2025-12-28 16:25:57 -06:00
Joel Wetzell
0248ca6973 add processor to encode json bytes 2025-12-28 16:17:56 -06:00
Joel Wetzell
4aa586427b change body field name 2025-12-28 13:39:01 -06:00
Joel Wetzell
6d3cf6692f fix route output with nil payload 2025-12-28 12:42:20 -06:00
Joel Wetzell
a263b10690 Merge pull request #31 from jwetzell/feat/http-server-output
support basic http server response control with body string template
2025-12-28 12:41:45 -06:00
Joel Wetzell
3ce2909b0f check that response writer has not been written too already 2025-12-28 12:32:22 -06:00
Joel Wetzell
b15e282d59 support basic http server response control with body string template for now 2025-12-28 12:25:25 -06:00
Joel Wetzell
f97f9b9fc9 propagate a ctx all the way from input to output of a route 2025-12-28 12:21:58 -06:00
Joel Wetzell
12de947f3d cleanup logging 2025-12-28 11:47:02 -06:00
Joel Wetzell
7335ba973a Merge pull request #30 from jwetzell/fix/multi-out-errors
fix error handling/short-circuiting in multi route matching
2025-12-28 11:40:34 -06:00
Joel Wetzell
a994286402 fix error handling/short-circuiting in multi route matching 2025-12-28 11:30:37 -06:00
Joel Wetzell
b639a5c786 Merge pull request #29 from jwetzell/feat/int-random
add processors to create random int/uint
2025-12-28 09:06:12 -06:00
Joel Wetzell
d170958d9b add processors to create random int/uint 2025-12-28 09:05:01 -06:00
Joel Wetzell
ed4c14e82b rework route to just process payload 2025-12-28 08:24:12 -06:00
Joel Wetzell
fcb1378784 fix route test 2025-12-27 23:02:37 -06:00
Joel Wetzell
97f4cbeace fix filename 2025-12-27 22:59:50 -06:00
Joel Wetzell
8ffc7d02a5 stuff values into context 2025-12-27 22:59:30 -06:00
Joel Wetzell
3458b52206 Merge pull request #28 from jwetzell/feat/artnet-module
add artnet module and processors
2025-12-26 11:59:59 -06:00
Joel Wetzell
e12b6e098e add artnet packet filter 2025-12-26 11:51:22 -06:00
Joel Wetzell
58cb7766fe change namespace 2025-12-26 11:51:22 -06:00
Joel Wetzell
407f1f3618 add basic artnet decode/encode 2025-12-26 11:51:22 -06:00
Joel Wetzell
3c0f555a6f Merge pull request #27 from jwetzell/chore/time-namespace
move timer and interval to time namespace
2025-12-26 11:18:44 -06:00
Joel Wetzell
6c6e50e0eb move timer and interval to time namespace 2025-12-26 11:17:29 -06:00
Joel Wetzell
0ed4d6669f don't use default mux 2025-12-26 11:01:30 -06:00
Joel Wetzell
cc37559bde use router.Stop 2025-12-26 10:43:04 -06:00
Joel Wetzell
18b307bca1 control flow of router running with better context handling and logging in cmd 2025-12-26 10:39:09 -06:00
Joel Wetzell
17106b5096 add more debug logging to router 2025-12-26 10:38:33 -06:00
Joel Wetzell
df9ffdda9e add debug log about waiting for modules to close 2025-12-26 10:24:31 -06:00
Joel Wetzell
479cf41ea0 use WaitGroup Go function 2025-12-26 10:24:15 -06:00
Joel Wetzell
af833cb212 cleanup debug.log 2025-12-26 10:23:39 -06:00
Joel Wetzell
a6a8e275ee remove unecessary logger 2025-12-26 10:23:33 -06:00
Joel Wetzell
6968539c59 cleanup logging in router 2025-12-26 10:19:29 -06:00
Joel Wetzell
e96913230b logging cleanup 2025-12-26 10:08:24 -06:00
Joel Wetzell
f1dff33704 centralize module logger creation 2025-12-26 09:51:55 -06:00
Joel Wetzell
ff426994e4 add tests for registration and error cases to script.expr 2025-12-24 20:53:46 -06:00
Joel Wetzell
a1275b3b69 add tests for error scenarios to script.js 2025-12-24 20:53:25 -06:00
Joel Wetzell
227d042feb add bitsize and registration tests for float.parse 2025-12-24 20:32:42 -06:00
Joel Wetzell
e53515267e tests for debug.log 2025-12-24 20:32:31 -06:00
Joel Wetzell
502586dec6 add base and bitsize to int.parse tests 2025-12-24 20:25:59 -06:00
Joel Wetzell
cad3714664 add base and bitsize to bad tests 2025-12-24 20:25:46 -06:00
Joel Wetzell
cf6519b594 more tests for script.js 2025-12-24 20:16:02 -06:00
Joel Wetzell
7dcac9470a tests for general processor stuff 2025-12-24 20:15:46 -06:00
Joel Wetzell
e5268f42f8 add more tests for int/uint parsers 2025-12-24 20:15:29 -06:00
Joel Wetzell
ccff105e37 test error cleanup 2025-12-24 19:45:24 -06:00
Joel Wetzell
6611821155 add tests for route 2025-12-24 19:16:52 -06:00
Joel Wetzell
7cde1c244a cleanup route interface 2025-12-24 19:16:46 -06:00
Joel Wetzell
70878e6df6 Merge pull request #26 from jwetzell/fix/slip-framer
fix incorrect slip frame encoding
2025-12-24 19:15:17 -06:00
Joel Wetzell
bd005da358 fix incorrect slip frame encoding 2025-12-24 19:13:33 -06:00
Joel Wetzell
25f2ec30c2 rework framer tests 2025-12-24 19:13:04 -06:00
Joel Wetzell
e679bf2b46 add test for string.create 2025-12-24 18:18:08 -06:00
Joel Wetzell
460d2f051d switch separator test to use GetFramer 2025-12-24 18:17:57 -06:00
Joel Wetzell
efdcbae5c4 no need for framer to return two values 2025-12-24 18:17:41 -06:00
Joel Wetzell
076a13f48a Merge pull request #25 from jwetzell/feat/sleep-processor
add a processor that sleeps
2025-12-24 16:33:41 -06:00
Joel Wetzell
971eea6e41 add a processor that sleeps 2025-12-24 16:32:44 -06:00
Joel Wetzell
1172159455 align variable names with struct name 2025-12-24 16:06:01 -06:00
Joel Wetzell
ecb415f321 add basic test for raw framer 2025-12-24 16:02:23 -06:00
Joel Wetzell
b552672011 update README 2025-12-24 15:18:32 -06:00
Joel Wetzell
168265f0a9 move docker building to main release workflow 2025-12-24 14:05:40 -06:00
Joel Wetzell
caee7b269c fix docker job name 2025-12-24 13:26:28 -06:00
Joel Wetzell
b0cc47236f add debug flag and working directory to launch.json 2025-12-24 13:26:09 -06:00
Joel Wetzell
f6c2b1d9ac Merge pull request #24 from jwetzell/feat/docker
add docker image
2025-12-24 13:24:29 -06:00
Joel Wetzell
f977b845be add github action to build docker image 2025-12-24 13:22:52 -06:00
Joel Wetzell
0166383978 set workdir 2025-12-24 13:22:45 -06:00
Joel Wetzell
66cfaa3091 update Go version in dockerfile 2025-12-24 13:22:34 -06:00
Joel Wetzell
0904d2fcb8 add more to dockerignore 2025-12-24 13:21:53 -06:00
Joel Wetzell
6a13c38e77 add dockerfile 2025-12-24 12:44:25 -06:00
Joel Wetzell
1a8ccfc64a align variable name 2025-12-24 10:54:30 -06:00
Joel Wetzell
2e3bb408c3 Merge pull request #23 from jwetzell/feat/configurable-number-parsing
make base and bitsize configurable for number parsers
2025-12-24 10:35:06 -06:00
Joel Wetzell
3d75165a61 cleanout TODO 2025-12-24 10:34:20 -06:00
Joel Wetzell
50f755f914 make base and bitsize configurable for number parsers 2025-12-24 10:34:06 -06:00
Joel Wetzell
b10e296d0a Merge pull request #22 from jwetzell/feat/udp-server-buffersize
allow configuring buffer size for udp server
2025-12-24 10:27:14 -06:00
Joel Wetzell
91c44420cb allow configuring buffer size for udp server 2025-12-24 10:24:24 -06:00
Joel Wetzell
c1b2fa714e update midi module section 2025-12-23 15:06:23 -06:00
Joel Wetzell
ff1949ce69 use errors.New when not formatting 2025-12-23 15:00:30 -06:00
Joel Wetzell
76583b1fb8 Merge pull request #20 from jwetzell/dependabot/go_modules/github.com/expr-lang/expr-1.17.7
Bump github.com/expr-lang/expr from 1.17.6 to 1.17.7
2025-12-23 14:29:05 -06:00
dependabot[bot]
e6433878b5 Bump github.com/expr-lang/expr from 1.17.6 to 1.17.7
Bumps [github.com/expr-lang/expr](https://github.com/expr-lang/expr) from 1.17.6 to 1.17.7.
- [Release notes](https://github.com/expr-lang/expr/releases)
- [Commits](https://github.com/expr-lang/expr/compare/v1.17.6...v1.17.7)

---
updated-dependencies:
- dependency-name: github.com/expr-lang/expr
  dependency-version: 1.17.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 20:27:34 +00:00
Joel Wetzell
3ad067cbb8 Merge pull request #19 from jwetzell/dependabot/go_modules/github.com/emiago/sipgo-1.0.1
Bump github.com/emiago/sipgo from 1.0.1-alpha.0.20251212165843-9c9bcdf9126f to 1.0.1
2025-12-23 14:26:28 -06:00
Joel Wetzell
30b406d601 Merge pull request #21 from jwetzell/dependabot/go_modules/github.com/nats-io/nats.go-1.48.0
Bump github.com/nats-io/nats.go from 1.47.0 to 1.48.0
2025-12-23 14:25:25 -06:00
dependabot[bot]
7315d40015 Bump github.com/nats-io/nats.go from 1.47.0 to 1.48.0
Bumps [github.com/nats-io/nats.go](https://github.com/nats-io/nats.go) from 1.47.0 to 1.48.0.
- [Release notes](https://github.com/nats-io/nats.go/releases)
- [Commits](https://github.com/nats-io/nats.go/compare/v1.47.0...v1.48.0)

---
updated-dependencies:
- dependency-name: github.com/nats-io/nats.go
  dependency-version: 1.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 17:33:17 +00:00
dependabot[bot]
e7105b8b39 Bump github.com/emiago/sipgo
Bumps [github.com/emiago/sipgo](https://github.com/emiago/sipgo) from 1.0.1-alpha.0.20251212165843-9c9bcdf9126f to 1.0.1.
- [Release notes](https://github.com/emiago/sipgo/releases)
- [Commits](https://github.com/emiago/sipgo/commits/v1.0.1)

---
updated-dependencies:
- dependency-name: github.com/emiago/sipgo
  dependency-version: 1.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 17:33:08 +00:00
Joel Wetzell
ec40194ecb add TODO 2025-12-21 15:13:06 -06:00
Joel Wetzell
85d8dc5787 cleanup error messages 2025-12-20 14:18:56 -06:00
Joel Wetzell
b27bfb1a6d don't wrap string.filter pattern 2025-12-20 10:59:33 -06:00
Joel Wetzell
fb9c0bc657 cleanup error messages 2025-12-20 07:50:22 -06:00
Joel Wetzell
d32d23041b add usage to cmd 2025-12-19 22:30:18 -06:00
Joel Wetzell
b4149df00a setup slog inside cmd with debug and json flag 2025-12-19 22:27:05 -06:00
Joel Wetzell
61bd4b64f5 setup loggers inside modules with attributes 2025-12-19 22:26:25 -06:00
Joel Wetzell
38d73881c9 return error don't just log it 2025-12-19 22:25:26 -06:00
Joel Wetzell
3138bdfcdb remove TODO's 2025-12-19 12:32:13 -06:00
Joel Wetzell
ba862300b2 fix config example 2025-12-19 09:02:05 -06:00
Joel Wetzell
e414bf336e Merge pull request #18 from jwetzell/test/script-processors
add basic tests for script processors
2025-12-18 20:06:21 -06:00
Joel Wetzell
da95fb5c75 add basic test for script.js 2025-12-18 20:04:52 -06:00
Joel Wetzell
1fdc30aa9e add basic test for script.expr 2025-12-18 20:04:41 -06:00
Joel Wetzell
128920dff9 fix typo in string.split test 2025-12-18 20:03:50 -06:00
Joel Wetzell
9cb7abcc8f add commit to version 2025-12-16 20:45:05 -06:00
Joel Wetzell
991111deba add version flag to cli command 2025-12-16 20:38:26 -06:00
Joel Wetzell
9b1ca9da96 Merge pull request #17 from jwetzell/feat/midi-message-create
add midi.message.create processor
2025-12-16 20:09:57 -06:00
Joel Wetzell
b726745aa2 add alternate casings for type 2025-12-16 20:08:32 -06:00
Joel Wetzell
a8bcf7a785 add ControlChange and ProgramChange 2025-12-16 20:02:16 -06:00
Joel Wetzell
32d8633402 add midi processor to create messages 2025-12-16 19:54:21 -06:00
Joel Wetzell
f3aaa86a1c Merge pull request #16 from jwetzell/feat/split-midi
split midi.client into input/output
2025-12-16 19:43:20 -06:00
Joel Wetzell
25b06ffc6d split midi.client into input/output 2025-12-16 19:24:20 -06:00
Joel Wetzell
ca3d662df7 Merge pull request #15 from jwetzell/dependabot/go_modules/gitlab.com/gomidi/midi/v2-2.3.18
Bump gitlab.com/gomidi/midi/v2 from 2.3.16 to 2.3.18
2025-12-15 13:53:09 -06:00
dependabot[bot]
86fe006af8 Bump gitlab.com/gomidi/midi/v2 from 2.3.16 to 2.3.18
Bumps [gitlab.com/gomidi/midi/v2](https://gitlab.com/gomidi/midi) from 2.3.16 to 2.3.18.
- [Commits](https://gitlab.com/gomidi/midi/compare/v2.3.16...v2.3.18)

---
updated-dependencies:
- dependency-name: gitlab.com/gomidi/midi/v2
  dependency-version: 2.3.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 18:11:35 +00:00
Joel Wetzell
d94232871e add launch.json 2025-12-14 12:14:03 -06:00
Joel Wetzell
5c8605104d update README 2025-12-13 13:59:54 -06:00
Joel Wetzell
e7b43b6637 Merge pull request #14 from jwetzell/feat/sip-server
add some SIP modules
2025-12-13 13:37:20 -06:00
Joel Wetzell
2221207036 update to main line of diago/sipgo 2025-12-13 13:35:33 -06:00
Joel Wetzell
dca1535f5a cleanup some error handling 2025-12-13 13:35:08 -06:00
Joel Wetzell
8b2eaf3ef4 add output to SIP call server 2025-12-13 12:59:24 -06:00
Joel Wetzell
c19837df1e add sip module that emits on every call 2025-12-13 12:33:52 -06:00
Joel Wetzell
783c333b46 fix property name 2025-12-13 12:33:00 -06:00
Joel Wetzell
2497c9c8e4 Merge pull request #13 from jwetzell/feat/midi-processors
add more MIDI processors
2025-12-13 08:52:47 -06:00
Joel Wetzell
54a8164dd3 add midi message unpack to pul values out of common MIDI types 2025-12-13 08:51:29 -06:00
Joel Wetzell
2c6a2f5a36 add midi message type filter 2025-12-13 08:50:52 -06:00
Joel Wetzell
92f91cf260 add dialed number to sip.dtmf.server output 2025-12-12 21:37:09 -06:00
Joel Wetzell
5c94cddc74 set mor loggers in SIP libraries 2025-12-11 21:04:07 -06:00
Joel Wetzell
7a0e945ecd move sip server under own namespace 2025-12-11 20:50:32 -06:00
Joel Wetzell
1eaabf2e75 move packages to correct place in go.mod 2025-12-11 20:50:32 -06:00
Joel Wetzell
955dcca8c6 override transport layer logger with dummy logger 2025-12-11 20:50:32 -06:00
Joel Wetzell
d312baeb6e add real dumb SIP server 2025-12-11 20:50:32 -06:00
Joel Wetzell
00f78b5a50 split out from net and misc module namespaces 2025-12-11 19:34:57 -06:00
Joel Wetzell
b59da597ba use Id function instead of accessing config directly 2025-12-10 07:44:55 -06:00
Joel Wetzell
8ca105a0b6 implement mqtt.Message for internal MQTTMessage type 2025-12-10 07:31:18 -06:00
Joel Wetzell
e027f22f8b Add MIT License to the project 2025-12-09 08:30:14 -06:00
Joel Wetzell
55f5767d47 Merge pull request #11 from jwetzell/feat/raw-framing-default
use RAW framer if no framer specified
2025-12-08 22:44:11 -06:00
Joel Wetzell
66435fc2ba use RAW framer if no framer specified 2025-12-08 22:21:34 -06:00
Joel Wetzell
a85b0e64e0 add RAW framer for passthrough 2025-12-08 19:31:42 -06:00
Joel Wetzell
202d1aea1f add SetupConn method to net.udp.client 2025-12-08 19:22:28 -06:00
Joel Wetzell
3a7b484c76 up buffer size for net.udp.server 2025-12-08 18:03:33 -06:00
Joel Wetzell
ffb39b1034 add tests for framers 2025-12-08 18:03:09 -06:00
Joel Wetzell
bb78b7b08b add tests for string.split 2025-12-07 14:14:46 -06:00
Joel Wetzell
48ac2640d0 add tests for error cases 2025-12-07 14:14:37 -06:00
Joel Wetzell
25324a4dcd Merge pull request #10 from jwetzell/chore/processor-tests
add some basic processor tests
2025-12-07 11:31:05 -06:00
Joel Wetzell
bebcd56d70 add release not config for more labels 2025-12-07 11:28:56 -06:00
Joel Wetzell
ce8e18dd4f fix labeler config 2025-12-07 11:27:15 -06:00
Joel Wetzell
f4021d858b add basic tests for uint.parse 2025-12-07 11:20:50 -06:00
Joel Wetzell
008d7665bd add basic tests for int.parse 2025-12-07 11:20:43 -06:00
Joel Wetzell
917f728f14 add basic tests for float.parse 2025-12-07 11:20:37 -06:00
Joel Wetzell
e3b6783a98 add missing err checking in freed.create 2025-12-07 11:13:10 -06:00
Joel Wetzell
5e7ebd1bf1 rename framing to framer 2025-12-07 10:42:51 -06:00
Joel Wetzell
0bd43ca0c3 rename processing to processor 2025-12-07 10:41:14 -06:00
Joel Wetzell
2e7feede28 fix naming conflict on import 2025-12-07 10:35:11 -06:00
Joel Wetzell
a5deea6447 move route and module into internal 2025-12-07 10:34:58 -06:00
Joel Wetzell
3f6914282b reorder functions 2025-12-06 23:53:32 -06:00
Joel Wetzell
7a01ce6691 convert Route to interface 2025-12-06 23:50:44 -06:00
Joel Wetzell
5be40a5abf add TODO 2025-12-06 23:48:39 -06:00
Joel Wetzell
388851f8bd inject router on HandleInput 2025-12-06 23:08:05 -06:00
Joel Wetzell
9a06949b5b move router to module New func 2025-12-06 23:00:45 -06:00
Joel Wetzell
8d8347fc4d fix import 2025-12-06 23:00:28 -06:00
Joel Wetzell
07108918f1 move config to internal 2025-12-06 22:42:38 -06:00
Joel Wetzell
2c6502b622 add output to net.udp.multicast 2025-12-06 22:36:55 -06:00
Joel Wetzell
4c0f7c1723 move framing creation into shared place 2025-12-06 16:02:35 -06:00
Joel Wetzell
04103cc6ca Merge pull request #9 from jwetzell/feat/serial-client
add serial client module
2025-12-06 14:00:07 -06:00
Joel Wetzell
66097f7297 add serial client module 2025-12-06 13:55:41 -06:00
Joel Wetzell
3eb3ccca0c cleanup copy/paste 2025-12-06 11:01:30 -06:00
Joel Wetzell
55452d9dd0 load udp addr on init in net.udp.server 2025-12-06 11:00:42 -06:00
Joel Wetzell
ed4f7dc1f7 switch to using only TCPConn in net.tcp.server 2025-12-06 10:58:18 -06:00
Joel Wetzell
190bdd8b44 reuse the same UDP client for every output 2025-12-06 10:49:36 -06:00
Joel Wetzell
a7f889f6b5 Merge pull request #8 from jwetzell/feat/udp-multicast
add net.udp.multicast module
2025-12-06 10:36:47 -06:00
Joel Wetzell
5c5111a25e add net.udp.multicast module 2025-12-06 10:35:25 -06:00
Joel Wetzell
b31729fafe switch net.udp.client conn to pointer 2025-12-06 09:02:41 -06:00
Joel Wetzell
dff5430eb4 switch net.tcp.client conn to pointer 2025-12-06 09:02:30 -06:00
Joel Wetzell
eaca0dbf86 Merge pull request #7 from jwetzell/feat/tcp-server-output
support output from net.tcp.server
2025-12-06 08:59:21 -06:00
Joel Wetzell
aa3a1032f3 logging tweaks 2025-12-06 08:39:34 -06:00
Joel Wetzell
51a62f7fb2 cleanup 2025-12-06 08:23:40 -06:00
Joel Wetzell
2c8efcea4b add output to TCP server 2025-12-05 23:18:37 -06:00
Joel Wetzell
df1882b8f7 update psn-go 2025-12-04 23:11:30 -06:00
Joel Wetzell
1c8346cf65 cleanup error messages 2025-12-04 16:35:03 -06:00
124 changed files with 7456 additions and 2174 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
Dockerfile
.github
.vscode
README.md
LICENSE

21
.github/labeler.yml vendored
View File

@@ -1,10 +1,23 @@
framing:
config:
- changed-files:
- any-glob-to-any-file: 'internal/framing/**'
- any-glob-to-any-file: 'internal/config/**'
processing:
framer:
- changed-files:
- any-glob-to-any-file: 'internal/processing/**'
- any-glob-to-any-file: 'internal/framer/**'
module:
- changed-files:
- any-glob-to-any-file: 'internal/module/**'
processor:
- changed-files:
- any-glob-to-any-file: 'internal/processor/**'
routing:
- changed-files:
- any-glob-to-any-file: 'internal/route/**'
- any-glob-to-any-file: 'router*'
cli:
- changed-files:

20
.github/release.yml vendored
View File

@@ -5,15 +5,25 @@ changelog:
labels:
- dependencies
categories:
- title: Framing 🖼
- title: Config
labels:
- framing
- title: Processing 🏭
- config
- title: Framer 🖼️
labels:
- processing
- framer
- title: Module 📦
labels:
- module
- title: Processor 🏭
labels:
- processor
- title: Routing 🛣️
labels:
- router
- route
- title: CLI ⌨️
labels:
- cmd
- cli
- title: Other Changes
labels:
- '*'

View File

@@ -3,7 +3,7 @@ name: showbridge release
on:
push:
tags:
- "*"
- "v*"
permissions:
contents: write
@@ -29,3 +29,40 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker:
runs-on: ubuntu-latest
needs: goreleaser
steps:
- name: Check out repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: |
jwetzell/showbridge
- name: Build and push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
push: true
context: ./
file: ./Dockerfile
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64,linux/arm/v7

18
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch showbridge",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "cmd/showbridge",
"args": ["--log-level", "debug"],
"cwd": "./"
}
]
}

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
ARG GO_VERSION=1.25.5
FROM golang:${GO_VERSION}-alpine AS build
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build ./cmd/showbridge
FROM scratch
WORKDIR /app
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /build/showbridge /app/showbridge
ENTRYPOINT [ "/app/showbridge" ]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Joel Wetzell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -6,24 +6,37 @@ Simple protocol router _/s_
</div>
<p align="center">
<a href="https://github.com/jwetzell/showbridge-go/releases">Releases</a> ·
<a href="https://docs.showbridge.io">Documentation</a>
</p>
### Supported Protocols
- HTTP
- client
- server
- UDP
- client
- server
- TCP
- client
- server
- [MQTT](https://mqtt.org/)
- client
- [NATS](https://nats.io/)
- client
- [PosiStageNet](https://posistage.net/)
- client
- MIDI
- client (not included in pre-built binaries yet)
- MIDI (not included in pre-built binaries yet)
- Serial (not included in pre-built binaries yet)
- [OSC](https://opensoundcontrol.stanford.edu/spec-1_0.html)
- [FreeD](https://ptzoptics.com/freed/)
- [SIP](https://en.wikipedia.org/wiki/Session_Initiation_Protocol)
### CLI Usage
```
NAME:
showbridge - Simple protocol router /s
USAGE:
showbridge [global options]
GLOBAL OPTIONS:
--config string path to config file (default: "./config.yaml")
--log-level string set log level (default: "info")
--log-format string log format to use (default: "text")
--help, -h show help
--version, -v print the version
```

View File

@@ -14,6 +14,8 @@ builds:
goarch:
- "amd64"
- "arm64"
ldflags:
- '-s -w -X main.version={{.RawVersion}}-{{.ShortCommit}}'
archives:
- formats: [tar.gz]

View File

@@ -2,49 +2,64 @@ package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"slices"
"sync"
"github.com/jwetzell/showbridge-go"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/urfave/cli/v3"
"sigs.k8s.io/yaml"
)
var (
version = "dev"
)
func main() {
cmd := &cli.Command{
Name: "showbridge",
Name: "showbridge",
Usage: "Simple protocol router /s",
Version: version,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config",
Value: "./config.yaml",
Usage: "path to config file",
},
&cli.StringFlag{
Name: "log-level",
Value: "info",
Usage: "set log level",
Validator: func(level string) error {
levels := []string{"debug", "info", "warn", "error"}
if !slices.Contains(levels, level) {
return fmt.Errorf("unknown log level: %s", level)
}
return nil
},
},
&cli.StringFlag{
Name: "log-format",
Value: "text",
Usage: "log format to use",
Validator: func(format string) error {
formats := []string{"text", "json"}
if !slices.Contains(formats, format) {
return fmt.Errorf("unknown log format: %s", format)
}
return nil
},
},
},
Action: func(ctx context.Context, c *cli.Command) error {
configPath := c.String("config")
if configPath == "" {
return fmt.Errorf("config value cannot be empty")
}
config, err := readConfig(configPath)
if err != nil {
return err
}
router, moduleErrors, routeErrors := showbridge.NewRouter(ctx, config)
for _, moduleError := range moduleErrors {
slog.Error("problem initializing module", "index", moduleError.Index, "error", moduleError.Error)
}
for _, routeError := range routeErrors {
slog.Error("problem initializing route", "index", routeError.Index, "error", routeError.Error)
}
router.Run()
return nil
},
Action: run,
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Interrupt)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
err := cmd.Run(ctx, os.Args)
@@ -54,19 +69,94 @@ func main() {
}
func readConfig(configPath string) (showbridge.Config, error) {
func readConfig(configPath string) (config.Config, error) {
cfg := config.Config{}
configBytes, err := os.ReadFile(configPath)
if err != nil {
return showbridge.Config{}, err
return config.Config{}, err
}
config := showbridge.Config{}
err = yaml.Unmarshal(configBytes, &config)
err = yaml.Unmarshal(configBytes, &cfg)
if err != nil {
return showbridge.Config{}, err
return config.Config{}, err
}
return config, nil
return cfg, nil
}
func run(ctx context.Context, c *cli.Command) error {
configPath := c.String("config")
if configPath == "" {
return errors.New("config value cannot be empty")
}
config, err := readConfig(configPath)
if err != nil {
return err
}
logLevel := slog.LevelInfo
logLevelFromFlag := c.String("log-level")
switch logLevelFromFlag {
case "debug":
logLevel = slog.LevelDebug
case "info":
logLevel = slog.LevelInfo
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}
logHandlerOptions := &slog.HandlerOptions{
Level: logLevel,
}
logOutput := os.Stderr
var logHandler slog.Handler
logFormat := c.String("log-format")
switch logFormat {
case "json":
logHandler = slog.NewJSONHandler(logOutput, logHandlerOptions)
case "text":
logHandler = slog.NewTextHandler(logOutput, logHandlerOptions)
default:
logHandler = slog.NewTextHandler(logOutput, logHandlerOptions)
}
slog.SetDefault(slog.New(logHandler))
commandLogger := slog.Default().With("component", "cmd")
router, moduleErrors, routeErrors := showbridge.NewRouter(config)
for _, moduleError := range moduleErrors {
commandLogger.Error("problem initializing module", "index", moduleError.Index, "error", moduleError.Error)
}
for _, routeError := range routeErrors {
commandLogger.Error("problem initializing route", "index", routeError.Index, "error", routeError.Error)
}
routerRunner := sync.WaitGroup{}
routerRunner.Go(func() {
router.Run(context.Background())
})
<-ctx.Done()
commandLogger.Debug("shutting down router")
router.Stop()
commandLogger.Debug("waiting for router to exit")
routerRunner.Wait()
return nil
}

View File

@@ -1,6 +0,0 @@
package showbridge
type Config struct {
Modules []ModuleConfig `json:"modules"`
Routes []RouteConfig `json:"routes"`
}

View File

@@ -1,6 +1,6 @@
modules:
- id: http
type: net.http.server
type: http.server
params:
port: 3000
- id: udp

52
go.mod
View File

@@ -1,38 +1,64 @@
module github.com/jwetzell/showbridge-go
go 1.25.3
go 1.25.5
require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/expr-lang/expr v1.17.6
github.com/emiago/diago v0.25.0
github.com/emiago/sipgo v1.1.1
github.com/expr-lang/expr v1.17.7
github.com/extism/go-sdk v1.7.1
github.com/jwetzell/artnet-go v0.2.1
github.com/jwetzell/free-d-go v0.1.0
github.com/jwetzell/osc-go v0.1.0
github.com/jwetzell/psn-go v0.2.1
github.com/nats-io/nats.go v1.47.0
github.com/urfave/cli/v3 v3.6.1
gitlab.com/gomidi/midi/v2 v2.3.16
modernc.org/quickjs v0.17.0
github.com/jwetzell/psn-go v0.3.0
github.com/nats-io/nats.go v1.48.0
github.com/urfave/cli/v3 v3.6.2
gitlab.com/gomidi/midi/v2 v2.3.18
go.bug.st/serial v1.6.4
modernc.org/quickjs v0.17.1
sigs.k8s.io/yaml v1.6.0
)
require (
github.com/creack/goselect v0.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect
github.com/go-audio/riff v1.0.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.8.26 // indirect
github.com/pion/srtp/v3 v3.0.9 // indirect
github.com/pion/transport/v3 v3.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/zaf/g711 v1.4.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/libquickjs v0.12.2 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 // indirect
modernc.org/libc v1.67.1 // indirect
modernc.org/libquickjs v0.12.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

123
go.sum
View File

@@ -1,70 +1,131 @@
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/emiago/diago v0.25.0 h1:YkjHahAMyIhvk5TKTMYoFJvsbNaJhyMBAiGggc97HDc=
github.com/emiago/diago v0.25.0/go.mod h1:HQNLzmwucATviInW5OqpnpqFjtz2jAtxKKWx8Nh98wo=
github.com/emiago/sipgo v1.1.1 h1:egwB7o9b3QpeTbqRFT9ECOYcWT/rw2UVTWA1qYG1HBs=
github.com/emiago/sipgo v1.1.1/go.mod h1:DuwAxBZhKMqIzQFPGZb1MVAGU6Wuxj64oTOhd5dx/FY=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw=
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/jwetzell/artnet-go v0.2.1 h1:iYTKWcwYrF5kBkYfkw2UbWvoueeA23iKEn7fR27mWZE=
github.com/jwetzell/artnet-go v0.2.1/go.mod h1:gli97Z32a0kMkZ6taoTiK7/lqHcF/dhiGjGJdx/PxqA=
github.com/jwetzell/free-d-go v0.1.0 h1:xHt6dvyit98X+OC3jVzV0aLidxbyzi3vI9QiYkteEtA=
github.com/jwetzell/free-d-go v0.1.0/go.mod h1:KmrkooRARRaxJTBSPvwt/6IMAIaHH1R8bSA8cwbbELw=
github.com/jwetzell/osc-go v0.1.0 h1:EXxup5VWBErHot2Ri4MFToPf6KCzLDTbCt2x6GLfw8I=
github.com/jwetzell/osc-go v0.1.0/go.mod h1:xLz0jTwebSxtx1TkKN1YVdeRqvpFNweDhTut5TE393A=
github.com/jwetzell/psn-go v0.2.1 h1:pNG6XNfVRTb4qctH6pJjRJ1ReYGnGgNRA4H7tNbmzRU=
github.com/jwetzell/psn-go v0.2.1/go.mod h1:bcEAeti4sQM375buujb3mIfmUstD4Aby18gq3ENb6+o=
github.com/jwetzell/psn-go v0.3.0 h1:WVpCEmExYE8a+I5hQak5jNJJp2x35VdGX/VuMUKPmhY=
github.com/jwetzell/psn-go v0.3.0/go.mod h1:bcEAeti4sQM375buujb3mIfmUstD4Aby18gq3ENb6+o=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.8.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc=
github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY=
github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
gitlab.com/gomidi/midi/v2 v2.3.16 h1:yufWSENyjnJ4LFQa9BerzUm4E4aLfTyzw5nmnCteO0c=
gitlab.com/gomidi/midi/v2 v2.3.16/go.mod h1:jDpP4O4skYi+7iVwt6Zyp18bd2M4hkjtMuw2cmgKgfw=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/zaf/g711 v1.4.0 h1:XZYkjjiAg9QTBnHqEg37m2I9q3IIDv5JRYXs2N8ma7c=
github.com/zaf/g711 v1.4.0/go.mod h1:eCDXt3dSp/kYYAoooba7ukD/Q75jvAaS4WOMr0l1Roo=
gitlab.com/gomidi/midi/v2 v2.3.18 h1:sj2fOhtvOe+zI8YJe8qTxLw5zv0ntULLUDwcFOaZQbI=
gitlab.com/gomidi/midi/v2 v2.3.18/go.mod h1:jDpP4O4skYi+7iVwt6Zyp18bd2M4hkjtMuw2cmgKgfw=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
@@ -77,18 +138,18 @@ modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/libquickjs v0.12.2 h1:XtF6iD+aoN/pEz1MAQjg1wMZT412pzmOlO11UGLW/wQ=
modernc.org/libquickjs v0.12.2/go.mod h1:n+vyuJ4mXh1pQYt1bJttvoCE+2t+MqCM8BFlcSDg/70=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libquickjs v0.12.3 h1:2IU9B6njBmce2PuYttJDkXeoLRV9WnvgP+eU5HAC8YI=
modernc.org/libquickjs v0.12.3/go.mod h1:iCsgVxnHTX3i0YPxxHBmJk0GLA5sVUHXWI/090UXgeE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/quickjs v0.17.0 h1:gYZRSCKVokyOiHhZEKwran/NoBaGjAaWk+xJq52SWfQ=
modernc.org/quickjs v0.17.0/go.mod h1:TE+wpAYX4V7Nvi/sHRUrd9l5L176ZIiS0as3MMObvC0=
modernc.org/quickjs v0.17.1 h1:CbYnbTf7ksZk9YZ1rRM2Ab1Zfi+X6s50kXiOhpd2NIg=
modernc.org/quickjs v0.17.1/go.mod h1:hATT7DIJc33I5Q/Fjffhm0tpUHNSqdKHma/ossibTA0=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=

View File

@@ -1,72 +0,0 @@
package showbridge
import (
"fmt"
"log/slog"
"net/http"
"time"
)
type HTTPClient struct {
config ModuleConfig
router *Router
client *http.Client
}
func init() {
RegisterModule(ModuleRegistration{
Type: "net.http.client",
New: func(config ModuleConfig) (Module, error) {
return &HTTPClient{config: config}, nil
},
})
}
func (hc *HTTPClient) Id() string {
return hc.config.Id
}
func (hc *HTTPClient) Type() string {
return hc.config.Type
}
func (hc *HTTPClient) RegisterRouter(router *Router) {
hc.router = router
}
func (hc *HTTPClient) Run() error {
hc.client = &http.Client{
Timeout: 10 * time.Second,
}
<-hc.router.Context.Done()
slog.Debug("router context done in module", "id", hc.config.Id)
return nil
}
func (hc *HTTPClient) Output(payload any) error {
payloadRequest, ok := payload.(*http.Request)
if !ok {
return fmt.Errorf("net.http.client is only able to output an http.Request")
}
if hc.client == nil {
return fmt.Errorf("net.http.client client is nil")
}
response, err := hc.client.Do(payloadRequest)
if err != nil {
return err
}
if hc.router != nil {
hc.router.HandleInput(hc.config.Id, response)
}
return nil
}

View File

@@ -1,107 +0,0 @@
package showbridge
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
)
type HTTPServer struct {
config ModuleConfig
Port uint16
router *Router
}
type ResponseData struct {
Message string `json:"message"`
Status string `json:"status"`
}
func init() {
RegisterModule(ModuleRegistration{
Type: "net.http.server",
New: func(config ModuleConfig) (Module, error) {
params := config.Params
port, ok := params["port"]
if !ok {
return nil, fmt.Errorf("net.http.server requires a port parameter")
}
portNum, ok := port.(float64)
if !ok {
return nil, fmt.Errorf("net.http.server port must be uint16")
}
return &HTTPServer{Port: uint16(portNum), config: config}, nil
},
})
}
func (hs *HTTPServer) Id() string {
return hs.config.Id
}
func (hs *HTTPServer) Type() string {
return hs.config.Type
}
func (hs *HTTPServer) RegisterRouter(router *Router) {
hs.router = router
}
func (hs *HTTPServer) HandleDefault(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
response := ResponseData{
Message: "routing successful",
Status: "ok",
}
if hs.router != nil {
routingErrors := hs.router.HandleInput(hs.config.Id, r)
if routingErrors != nil {
w.WriteHeader(http.StatusInternalServerError)
response.Status = "error"
response.Message = "routing failed"
} else {
w.WriteHeader(http.StatusOK)
response.Message = "routing successful"
}
} else {
w.WriteHeader(http.StatusInternalServerError)
response.Message = "no router registered"
response.Status = "error"
}
json.NewEncoder(w).Encode(response)
}
func (hs *HTTPServer) Run() error {
http.HandleFunc("/", hs.HandleDefault)
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", hs.Port),
Handler: http.DefaultServeMux,
}
go func() {
<-hs.router.Context.Done()
slog.Debug("router context done in module", "id", hs.config.Id)
httpServer.Close()
}()
err := httpServer.ListenAndServe()
slog.Debug("net.http.server closed", "id", hs.config.Id)
if err != nil {
return err
}
<-hs.router.Context.Done()
return nil
}
func (hs *HTTPServer) Output(payload any) error {
return fmt.Errorf("net.http.server output is not implemented")
}

23
internal/config/config.go Normal file
View File

@@ -0,0 +1,23 @@
package config
type Config struct {
Modules []ModuleConfig `json:"modules"`
Routes []RouteConfig `json:"routes"`
}
type ModuleConfig struct {
Id string `json:"id"`
Type string `json:"type"`
Params map[string]any `json:"params"`
}
type RouteConfig struct {
Input string `json:"input"`
Processors []ProcessorConfig `json:"processors"`
Output string `json:"output"`
}
type ProcessorConfig struct {
Type string `json:"type"`
Params map[string]any `json:"params"`
}

25
internal/framer/framer.go Normal file
View File

@@ -0,0 +1,25 @@
package framer
type Framer interface {
Decode([]byte) [][]byte
Encode([]byte) []byte
Clear()
Buffer() []byte
}
func GetFramer(framingType string) Framer {
switch framingType {
case "CR":
return NewByteSeparatorFramer([]byte{'\r'})
case "LF":
return NewByteSeparatorFramer([]byte{'\n'})
case "CRLF":
return NewByteSeparatorFramer([]byte{'\r', '\n'})
case "SLIP":
return NewSlipFramer()
case "RAW":
return NewRawFramer()
default:
return nil
}
}

View File

@@ -0,0 +1,15 @@
package framer_test
import (
"testing"
"github.com/jwetzell/showbridge-go/internal/framer"
)
func TestNilGetFramer(t *testing.T) {
nilFramer := framer.GetFramer("asldfiudchuehrkbjbkjrbb")
if nilFramer != nil {
t.Fatalf("Expected nil framer, got %v", nilFramer)
}
}

23
internal/framer/raw.go Normal file
View File

@@ -0,0 +1,23 @@
package framer
type RawFramer struct{}
func NewRawFramer() *RawFramer {
return &RawFramer{}
}
func (rf *RawFramer) Decode(data []byte) [][]byte {
return [][]byte{data}
}
func (rf *RawFramer) Encode(data []byte) []byte {
return data
}
func (rf *RawFramer) Clear() {
// NOTE(jwetzell): no internal state to clear
}
func (rf *RawFramer) Buffer() []byte {
return []byte{}
}

View File

@@ -0,0 +1,78 @@
package framer_test
import (
"slices"
"testing"
"github.com/jwetzell/showbridge-go/internal/framer"
)
func TestGoodRawFramerDecode(t *testing.T) {
tests := []struct {
name string
framer framer.Framer
input []byte
expected [][]byte
}{
{
name: "basic raw framer",
framer: framer.GetFramer("RAW"),
input: []byte("Hello\nWorld\nThis is a test\n"),
expected: [][]byte{
[]byte("Hello\nWorld\nThis is a test\n"),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
frames := test.framer.Decode(test.input)
if len(frames) != len(test.expected) {
t.Fatalf("raw framer got %d frames, expected %d", len(frames), len(test.expected))
}
for i, frame := range frames {
if !slices.Equal(frame, test.expected[i]) {
t.Errorf("raw framer frame %d got %s, expected %s", i, frame, test.expected[i])
}
}
})
}
}
func TestGoodRawFramerEncode(t *testing.T) {
tests := []struct {
name string
framer framer.Framer
expected []byte
input []byte
}{
{
name: "basic raw framer",
framer: framer.GetFramer("RAW"),
expected: []byte("Hello\nWorld\nThis is a test\n"),
input: []byte("Hello\nWorld\nThis is a test\n"),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
frame := test.framer.Encode(test.input)
if !slices.Equal(frame, test.expected) {
t.Fatalf("raw frame encode got %s, expected %s", frame, test.expected)
}
})
}
}
func TestRawFramerBuffer(t *testing.T) {
framer := framer.GetFramer("RAW")
framer.Decode([]byte("Hello, World!"))
if !slices.Equal(framer.Buffer(), []byte{}) {
t.Fatalf("raw framer buffer got %s, expected empty", framer.Buffer())
}
framer.Clear()
if !slices.Equal(framer.Buffer(), []byte{}) {
t.Fatalf("raw framer buffer got %s, expected empty after clear", framer.Buffer())
}
}

View File

@@ -1,4 +1,4 @@
package framing
package framer
import (
"bytes"
@@ -35,3 +35,7 @@ func (bsf *ByteSeparatorFramer) Encode(data []byte) []byte {
func (bsf *ByteSeparatorFramer) Clear() {
bsf.buffer = []byte{}
}
func (bsf *ByteSeparatorFramer) Buffer() []byte {
return bsf.buffer
}

View File

@@ -0,0 +1,130 @@
package framer_test
import (
"slices"
"testing"
"github.com/jwetzell/showbridge-go/internal/framer"
)
func TestGoodSeparatorFramerDecode(t *testing.T) {
tests := []struct {
name string
framer framer.Framer
input []byte
expected [][]byte
buffer []byte
}{
{
name: "new line separator",
framer: framer.GetFramer("LF"),
input: []byte("Hello\nWorld\nThis is a test\n"),
expected: [][]byte{
[]byte("Hello"),
[]byte("World"),
[]byte("This is a test"),
},
buffer: []byte{},
},
{
name: "CR separator",
framer: framer.GetFramer("CR"),
input: []byte("Hello\rWorld\rThis is a test\r"),
expected: [][]byte{
[]byte("Hello"),
[]byte("World"),
[]byte("This is a test"),
},
buffer: []byte{},
},
{
name: "CRLF separator",
framer: framer.GetFramer("CRLF"),
input: []byte("Hello\r\nWorld\r\nThis is a test\r\n"),
expected: [][]byte{
[]byte("Hello"),
[]byte("World"),
[]byte("This is a test"),
},
buffer: []byte{},
},
{
name: "extra data after separator",
framer: framer.GetFramer("CRLF"),
input: []byte("Hello\r\nWorld\r\nThis is a test\r\nextra"),
expected: [][]byte{
[]byte("Hello"),
[]byte("World"),
[]byte("This is a test"),
},
buffer: []byte("extra"),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
frames := test.framer.Decode(test.input)
if len(frames) != len(test.expected) {
t.Fatalf("separator framer got %d frames, expected %d", len(frames), len(test.expected))
}
for i, frame := range frames {
if !slices.Equal(frame, test.expected[i]) {
t.Errorf("separator framer frame %d got %s, expected %s", i, frame, test.expected[i])
}
}
if !slices.Equal(test.framer.Buffer(), test.buffer) {
t.Fatalf("separator framer buffer got %s, expected %s", test.framer.Buffer(), test.buffer)
}
})
}
}
func TestGoodSeparatorFramerEncode(t *testing.T) {
tests := []struct {
name string
framer framer.Framer
input []byte
expected []byte
}{
{
name: "new line separator",
framer: framer.GetFramer("LF"),
input: []byte("Hello"),
expected: []byte("Hello\n"),
},
{
name: "CR separator",
framer: framer.GetFramer("CR"),
input: []byte("Hello"),
expected: []byte("Hello\r"),
},
{
name: "CRLF separator",
framer: framer.GetFramer("CRLF"),
input: []byte("Hello"),
expected: []byte("Hello\r\n"),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
frame := test.framer.Encode(test.input)
if !slices.Equal(frame, test.expected) {
t.Fatalf("separator framer got %s, expected %s", frame, test.expected)
}
})
}
}
func TestSeparatorFrameBuffer(t *testing.T) {
framer := framer.GetFramer("LF")
framer.Decode([]byte("Hello\nWorld\nThis is a test\nextra"))
if !slices.Equal(framer.Buffer(), []byte("extra")) {
t.Fatalf("separator framer buffer got %s, expected %s", framer.Buffer(), []byte("extra"))
}
framer.Clear()
if !slices.Equal(framer.Buffer(), []byte{}) {
t.Fatalf("separator framer buffer got %s, expected empty slice", framer.Buffer())
}
}

View File

@@ -1,4 +1,4 @@
package framing
package framer
type SlipFramer struct {
buffer []byte
@@ -59,9 +59,9 @@ func (sf *SlipFramer) Encode(data []byte) []byte {
for _, byteToEncode := range data {
switch byteToEncode {
case END:
encodedBytes = append(encodedBytes, ESC_END)
encodedBytes = append(encodedBytes, ESC, ESC_END)
case ESC:
encodedBytes = append(encodedBytes, ESC_ESC)
encodedBytes = append(encodedBytes, ESC, ESC_ESC)
default:
encodedBytes = append(encodedBytes, byteToEncode)
}
@@ -74,3 +74,7 @@ func (sf *SlipFramer) Encode(data []byte) []byte {
func (sf *SlipFramer) Clear() {
sf.buffer = []byte{}
}
func (sf *SlipFramer) Buffer() []byte {
return sf.buffer
}

View File

@@ -0,0 +1,110 @@
package framer_test
import (
"slices"
"testing"
"github.com/jwetzell/showbridge-go/internal/framer"
)
func TestGoodSLIPFramerDecode(t *testing.T) {
tests := []struct {
name string
framer framer.Framer
input []byte
expected [][]byte
buffer []byte
}{
{
name: "OSC SLIP messages",
framer: framer.GetFramer("SLIP"),
input: []byte{0xc0, 0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0xc0},
expected: [][]byte{
{0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00},
},
buffer: []byte{},
},
{
name: "SLIP decode escaped end",
framer: framer.GetFramer("SLIP"),
expected: [][]byte{{0xc0}},
input: []byte{0xc0, 0xdb, 0xdc, 0xc0},
buffer: []byte{},
},
{
name: "SLIP decode escaped escape",
framer: framer.GetFramer("SLIP"),
expected: [][]byte{{0xdb}},
input: []byte{0xc0, 0xdb, 0xdd, 0xc0},
buffer: []byte{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
frames := test.framer.Decode(test.input)
if len(frames) != len(test.expected) {
t.Fatalf("SLIP framer got %d frames, expected %d", len(frames), len(test.expected))
}
for i, frame := range frames {
if !slices.Equal(frame, test.expected[i]) {
t.Errorf("SLIP framer frame %d got %s, expected %s", i, frame, test.expected[i])
}
}
if !slices.Equal(test.framer.Buffer(), test.buffer) {
t.Fatalf("SLIP framer buffer got %s, expected %s", test.framer.Buffer(), test.buffer)
}
})
}
}
func TestGoodSLIPFramerEncode(t *testing.T) {
tests := []struct {
name string
framer framer.Framer
input []byte
expected []byte
}{
{
name: "OSC SLIP messages",
framer: framer.GetFramer("SLIP"),
input: []byte{
0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00,
},
expected: []byte{0xc0, 0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0xc0},
},
{
name: "SLIP encode end",
framer: framer.GetFramer("SLIP"),
input: []byte{0xc0},
expected: []byte{0xc0, 0xdb, 0xdc, 0xc0},
},
{
name: "SLIP encode esc",
framer: framer.GetFramer("SLIP"),
input: []byte{0xdb},
expected: []byte{0xc0, 0xdb, 0xdd, 0xc0},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
frame := test.framer.Encode(test.input)
if !slices.Equal(frame, test.expected) {
t.Fatalf("SLIP framer frame got %s, expected %s", frame, test.expected)
}
})
}
}
func TestSlipFramerBuffer(t *testing.T) {
framer := framer.GetFramer("SLIP")
framer.Decode([]byte{0xc0, 0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0xc0, 0xc0, 0x45})
if !slices.Equal(framer.Buffer(), []byte{0x45}) {
t.Fatalf("SLIP framer buffer got %s, expected %s", framer.Buffer(), []byte{0x45})
}
framer.Clear()
if !slices.Equal(framer.Buffer(), []byte{}) {
t.Fatalf("SLIP framer buffer got %s, expected empty slice", framer.Buffer())
}
}

View File

@@ -1,7 +0,0 @@
package framing
type Framer interface {
Decode([]byte) [][]byte
Encode([]byte) []byte
Clear()
}

View File

@@ -0,0 +1,81 @@
package module
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
type HTTPClient struct {
config config.ModuleConfig
ctx context.Context
client *http.Client
router route.RouteIO
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "http.client",
New: func(config config.ModuleConfig) (Module, error) {
return &HTTPClient{config: config, logger: CreateLogger(config)}, nil
},
})
}
func (hc *HTTPClient) Id() string {
return hc.config.Id
}
func (hc *HTTPClient) Type() string {
return hc.config.Type
}
func (hc *HTTPClient) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("http.client unable to get router from context")
}
hc.router = router
hc.ctx = ctx
hc.client = &http.Client{
Timeout: 10 * time.Second,
}
<-hc.ctx.Done()
hc.logger.Debug("done")
return nil
}
func (hc *HTTPClient) Output(ctx context.Context, payload any) error {
payloadRequest, ok := payload.(*http.Request)
if !ok {
return errors.New("http.client is only able to output an http.Request")
}
if hc.client == nil {
return errors.New("http.client client is nil")
}
response, err := hc.client.Do(payloadRequest)
if err != nil {
return err
}
if hc.router != nil {
hc.router.HandleInput(hc.ctx, hc.Id(), response)
}
return nil
}

View File

@@ -0,0 +1,206 @@
package module
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
"github.com/jwetzell/showbridge-go/internal/route"
)
type HTTPServer struct {
config config.ModuleConfig
Port uint16
ctx context.Context
router route.RouteIO
logger *slog.Logger
}
type ResponseIOError struct {
Index int `json:"index"`
OutputErrors []string `json:"outputErrors"`
ProcessError *string `json:"processError"`
InputError *string `json:"inputError"`
}
type IOResponseData struct {
IOErrors []ResponseIOError `json:"ioErrors"`
Message string `json:"message"`
Status string `json:"status"`
}
type httpServerContextKey string
type HTTPServerResponseWriter struct {
http.ResponseWriter
done bool
}
func (hsrw *HTTPServerResponseWriter) WriteHeader(status int) {
hsrw.done = true
hsrw.ResponseWriter.WriteHeader(status)
}
func (hsrw *HTTPServerResponseWriter) Write(data []byte) (int, error) {
hsrw.done = true
return hsrw.ResponseWriter.Write(data)
}
func init() {
RegisterModule(ModuleRegistration{
Type: "http.server",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
port, ok := params["port"]
if !ok {
return nil, errors.New("http.server requires a port parameter")
}
portNum, ok := port.(float64)
if !ok {
return nil, errors.New("http.server port must be uint16")
}
return &HTTPServer{Port: uint16(portNum), config: config, logger: CreateLogger(config)}, nil
},
})
}
func (hs *HTTPServer) Id() string {
return hs.config.Id
}
func (hs *HTTPServer) Type() string {
return hs.config.Type
}
func (hs *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
responseWriter := HTTPServerResponseWriter{ResponseWriter: w}
response := IOResponseData{
Message: "routing successful",
Status: "ok",
}
if hs.router != nil {
inputContext := context.WithValue(hs.ctx, httpServerContextKey("responseWriter"), &responseWriter)
aRouteFound, routingErrors := hs.router.HandleInput(inputContext, hs.Id(), r)
if !responseWriter.done {
if aRouteFound {
if routingErrors != nil {
w.WriteHeader(http.StatusInternalServerError)
response.Status = "error"
response.Message = "routing failed"
response.IOErrors = []ResponseIOError{}
for _, responseIOError := range routingErrors {
errorToAdd := ResponseIOError{
Index: responseIOError.Index,
}
if responseIOError.InputError != nil {
errorMsg := responseIOError.InputError.Error()
errorToAdd.InputError = &errorMsg
}
if responseIOError.ProcessError != nil {
errorMsg := responseIOError.ProcessError.Error()
errorToAdd.ProcessError = &errorMsg
}
if responseIOError.OutputErrors != nil {
outputErrorMsgs := []string{}
for _, outputError := range responseIOError.OutputErrors {
outputErrorMsgs = append(outputErrorMsgs, outputError.Error())
}
errorToAdd.OutputErrors = outputErrorMsgs
}
response.IOErrors = append(response.IOErrors, errorToAdd)
}
json.NewEncoder(w).Encode(response)
return
} else {
w.WriteHeader(http.StatusOK)
response.Message = "routing successful"
json.NewEncoder(w).Encode(response)
return
}
} else {
w.WriteHeader(http.StatusNotFound)
response.Status = "error"
response.Message = "no matching routes found"
json.NewEncoder(w).Encode(response)
return
}
}
} else {
w.WriteHeader(http.StatusInternalServerError)
response.Message = "no router registered"
response.Status = "error"
json.NewEncoder(w).Encode(response)
return
}
}
func (hs *HTTPServer) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("http.server unable to get router from context")
}
hs.router = router
hs.ctx = ctx
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", hs.Port),
Handler: hs,
}
go func() {
<-hs.ctx.Done()
httpServer.Close()
}()
err := httpServer.ListenAndServe()
// TODO(jwetzell): handle server closed error differently
if err != nil {
if err.Error() != "http: Server closed" {
return err
}
}
<-hs.ctx.Done()
hs.logger.Debug("done")
return nil
}
func (hs *HTTPServer) Output(ctx context.Context, payload any) error {
responseWriter, ok := ctx.Value(httpServerContextKey("responseWriter")).(*HTTPServerResponseWriter)
if !ok {
return errors.New("http.server output must originate from an http.server input")
}
payloadResponse, ok := payload.(processor.HTTPResponse)
if !ok {
return errors.New("http.server is only able to output HTTPResponse")
}
if responseWriter.done {
return errors.New("http.server response writer has already been written to")
}
responseWriter.WriteHeader(payloadResponse.Status)
responseWriter.Write(payloadResponse.Body)
return nil
}

View File

@@ -0,0 +1,90 @@
//go:build cgo
package module
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
"gitlab.com/gomidi/midi/v2"
_ "gitlab.com/gomidi/midi/v2/drivers/rtmididrv"
)
type MIDIInput struct {
config config.ModuleConfig
ctx context.Context
router route.RouteIO
Port string
SendFunc func(midi.Message) error
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "midi.input",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
port, ok := params["port"]
if !ok {
return nil, errors.New("midi.input requires a port parameter")
}
portString, ok := port.(string)
if !ok {
return nil, errors.New("midi.input port must be a string")
}
return &MIDIInput{config: config, Port: portString, logger: CreateLogger(config)}, nil
},
})
}
func (mi *MIDIInput) Id() string {
return mi.config.Id
}
func (mi *MIDIInput) Type() string {
return mi.config.Type
}
func (mi *MIDIInput) Run(ctx context.Context) error {
defer midi.CloseDriver()
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("midi.input unable to get router from context")
}
mi.router = router
mi.ctx = ctx
in, err := midi.FindInPort(mi.Port)
if err != nil {
return fmt.Errorf("midi.input can't find input port: %s", mi.Port)
}
stop, err := midi.ListenTo(in, func(msg midi.Message, timestampms int32) {
if mi.router != nil {
mi.router.HandleInput(mi.ctx, mi.Id(), msg)
}
}, midi.UseSysEx())
if err != nil {
return err
}
defer stop()
<-mi.ctx.Done()
mi.logger.Debug("done")
return nil
}
func (mi *MIDIInput) Output(ctx context.Context, payload any) error {
return errors.New("midi.input output is not implemented")
}

View File

@@ -0,0 +1,97 @@
//go:build cgo
package module
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
"gitlab.com/gomidi/midi/v2"
_ "gitlab.com/gomidi/midi/v2/drivers/rtmididrv"
)
type MIDIOutput struct {
config config.ModuleConfig
ctx context.Context
router route.RouteIO
Port string
SendFunc func(midi.Message) error
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "midi.output",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
port, ok := params["port"]
if !ok {
return nil, errors.New("midi.output requires a port parameter")
}
portString, ok := port.(string)
if !ok {
return nil, errors.New("midi.output port must be a string")
}
return &MIDIOutput{config: config, Port: portString, logger: CreateLogger(config)}, nil
},
})
}
func (mo *MIDIOutput) Id() string {
return mo.config.Id
}
func (mo *MIDIOutput) Type() string {
return mo.config.Type
}
func (mo *MIDIOutput) Run(ctx context.Context) error {
defer midi.CloseDriver()
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("midi.output unable to get router from context")
}
mo.router = router
mo.ctx = ctx
out, err := midi.FindOutPort(mo.Port)
if err != nil {
return fmt.Errorf("midi.output can't find output port: %s", mo.Port)
}
send, err := midi.SendTo(out)
if err != nil {
return err
}
mo.SendFunc = send
<-mo.ctx.Done()
mo.logger.Debug("done")
return nil
}
func (mo *MIDIOutput) Output(ctx context.Context, payload any) error {
if mo.SendFunc == nil {
return errors.New("midi.output output is not setup")
}
payloadMessage, ok := payload.(midi.Message)
if !ok {
return errors.New("midi.output can only ouptut midi.Message")
}
return mo.SendFunc(payloadMessage)
}

View File

@@ -1,33 +1,30 @@
package showbridge
package module
import (
"context"
"fmt"
"log/slog"
"sync"
"github.com/jwetzell/showbridge-go/internal/config"
)
type ModuleError struct {
Index int
Config ModuleConfig
Config config.ModuleConfig
Error error
}
type Module interface {
Id() string
Type() string
RegisterRouter(*Router)
Run() error
Output(any) error
}
type ModuleConfig struct {
Id string `json:"id"`
Type string `json:"type"`
Params map[string]any `json:"params"`
Run(context.Context) error
Output(context.Context, any) error
}
type ModuleRegistration struct {
Type string `json:"type"`
New func(ModuleConfig) (Module, error)
New func(config.ModuleConfig) (Module, error)
}
func RegisterModule(mod ModuleRegistration) {
@@ -42,13 +39,17 @@ func RegisterModule(mod ModuleRegistration) {
moduleRegistryMu.Lock()
defer moduleRegistryMu.Unlock()
if _, ok := moduleRegistry[string(mod.Type)]; ok {
if _, ok := ModuleRegistry[string(mod.Type)]; ok {
panic(fmt.Sprintf("module already registered: %s", mod.Type))
}
moduleRegistry[string(mod.Type)] = mod
ModuleRegistry[string(mod.Type)] = mod
}
var (
moduleRegistryMu sync.RWMutex
moduleRegistry = make(map[string]ModuleRegistration)
ModuleRegistry = make(map[string]ModuleRegistration)
)
func CreateLogger(config config.ModuleConfig) *slog.Logger {
return slog.Default().With("component", "module", "id", config.Id, "type", config.Type)
}

View File

@@ -0,0 +1,135 @@
package module
import (
"context"
"errors"
"log/slog"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
type MQTTClient struct {
config config.ModuleConfig
ctx context.Context
router route.RouteIO
Broker string
ClientID string
Topic string
client mqtt.Client
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "mqtt.client",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
broker, ok := params["broker"]
if !ok {
return nil, errors.New("mqtt.client requires a broker parameter")
}
brokerString, ok := broker.(string)
if !ok {
return nil, errors.New("mqtt.client broker must be string")
}
topic, ok := params["topic"]
if !ok {
return nil, errors.New("mqtt.client requires a topic parameter")
}
topicString, ok := topic.(string)
if !ok {
return nil, errors.New("mqtt.client topic must be string")
}
clientId, ok := params["clientId"]
if !ok {
return nil, errors.New("mqtt.client requires a clientId parameter")
}
clientIdString, ok := clientId.(string)
if !ok {
return nil, errors.New("mqtt.client clientId must be string")
}
return &MQTTClient{config: config, Broker: brokerString, Topic: topicString, ClientID: clientIdString, logger: CreateLogger(config)}, nil
},
})
}
func (mc *MQTTClient) Id() string {
return mc.config.Id
}
func (mc *MQTTClient) Type() string {
return mc.config.Type
}
func (mc *MQTTClient) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("mqtt.client unable to get router from context")
}
mc.router = router
mc.ctx = ctx
opts := mqtt.NewClientOptions()
opts.AddBroker(mc.Broker)
opts.SetClientID(mc.ClientID)
opts.SetAutoReconnect(true)
opts.SetCleanSession(false)
opts.OnConnect = func(c mqtt.Client) {
token := mc.client.Subscribe(mc.Topic, 1, func(c mqtt.Client, m mqtt.Message) {
mc.router.HandleInput(mc.ctx, mc.Id(), m)
})
token.Wait()
}
mc.client = mqtt.NewClient(opts)
token := mc.client.Connect()
token.Wait()
err := token.Error()
if err != nil {
return err
}
<-mc.ctx.Done()
mc.logger.Debug("done")
return nil
}
func (mc *MQTTClient) Output(ctx context.Context, payload any) error {
payloadMessage, ok := payload.(mqtt.Message)
if !ok {
return errors.New("mqtt.client is only able to output a MQTTMessage")
}
if mc.client == nil {
return errors.New("mqtt.client client is not setup")
}
if !mc.client.IsConnected() {
return errors.New("mqtt.client is not connected")
}
token := mc.client.Publish(payloadMessage.Topic(), payloadMessage.Qos(), payloadMessage.Retained(), payloadMessage.Payload())
token.Wait()
return token.Error()
}

View File

@@ -0,0 +1,123 @@
package module
import (
"context"
"errors"
"log/slog"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
"github.com/jwetzell/showbridge-go/internal/route"
"github.com/nats-io/nats.go"
)
type NATSClient struct {
config config.ModuleConfig
ctx context.Context
router route.RouteIO
URL string
Subject string
client *nats.Conn
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "nats.client",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
url, ok := params["url"]
if !ok {
return nil, errors.New("nats.client requires a url parameter")
}
urlString, ok := url.(string)
if !ok {
return nil, errors.New("nats.client url must be string")
}
subject, ok := params["subject"]
if !ok {
return nil, errors.New("nats.client requires a subject parameter")
}
subjectString, ok := subject.(string)
if !ok {
return nil, errors.New("nats.client subject must be string")
}
return &NATSClient{config: config, URL: urlString, Subject: subjectString, logger: CreateLogger(config)}, nil
},
})
}
func (nc *NATSClient) Id() string {
return nc.config.Id
}
func (nc *NATSClient) Type() string {
return nc.config.Type
}
func (nc *NATSClient) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("nats.client unable to get router from context")
}
nc.router = router
nc.ctx = ctx
client, err := nats.Connect(nc.URL, nats.RetryOnFailedConnect(true))
if err != nil {
return err
}
nc.client = client
defer client.Drain()
defer client.Close()
sub, err := nc.client.Subscribe(nc.Subject, func(msg *nats.Msg) {
if nc.router != nil {
nc.router.HandleInput(nc.ctx, nc.Id(), msg)
}
})
if err != nil {
return err
}
defer sub.Unsubscribe()
<-nc.ctx.Done()
nc.logger.Debug("done")
return nil
}
func (nc *NATSClient) Output(ctx context.Context, payload any) error {
payloadMessage, ok := payload.(processor.NATSMessage)
if !ok {
return errors.New("nats.client is only able to output NATSMessage")
}
if nc.client == nil {
return errors.New("nats.client client is not setup")
}
if !nc.client.IsConnected() {
return errors.New("nats.client is not connected")
}
err := nc.client.Publish(payloadMessage.Subject, payloadMessage.Payload)
return err
}

View File

@@ -1,27 +1,33 @@
package showbridge
package module
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"time"
"github.com/jwetzell/psn-go"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
type PSNClient struct {
config ModuleConfig
config config.ModuleConfig
conn *net.UDPConn
router *Router
ctx context.Context
router route.RouteIO
decoder *psn.Decoder
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "net.psn.client",
New: func(config ModuleConfig) (Module, error) {
Type: "psn.client",
New: func(config config.ModuleConfig) (Module, error) {
return &PSNClient{config: config, decoder: psn.NewDecoder()}, nil
return &PSNClient{config: config, decoder: psn.NewDecoder(), logger: CreateLogger(config)}, nil
},
})
}
@@ -34,11 +40,14 @@ func (pc *PSNClient) Type() string {
return pc.config.Type
}
func (pc *PSNClient) RegisterRouter(router *Router) {
pc.router = router
}
func (pc *PSNClient) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
func (pc *PSNClient) Run() error {
if !ok {
return errors.New("psn.client unable to get router from context")
}
pc.router = router
pc.ctx = ctx
addr, err := net.ResolveUDPAddr("udp", "236.10.10.10:56565")
if err != nil {
@@ -56,9 +65,9 @@ func (pc *PSNClient) Run() error {
buffer := make([]byte, 2048)
for {
select {
case <-pc.router.Context.Done():
case <-pc.ctx.Done():
// TODO(jwetzell): cleanup?
slog.Debug("router context done in module", "id", pc.config.Id)
pc.logger.Debug("done")
return nil
default:
pc.conn.SetDeadline(time.Now().Add(time.Millisecond * 200))
@@ -76,21 +85,22 @@ func (pc *PSNClient) Run() error {
message := buffer[:numBytes]
err := pc.decoder.Decode(message)
if err != nil {
slog.Error("net.psn.client problem decoding psn traffic", "id", pc.config.Id, "error", err)
pc.logger.Error("problem decoding psn traffic", "error", err)
}
if pc.router != nil {
// TODO(jwetzell): better input handling
for _, tracker := range pc.decoder.Trackers {
pc.router.HandleInput(pc.config.Id, tracker)
pc.router.HandleInput(pc.ctx, pc.Id(), tracker)
}
} else {
slog.Error("net.psn.client has no router", "id", pc.config.Id)
pc.logger.Error("has no router")
}
}
}
}
}
func (pc *PSNClient) Output(payload any) error {
return fmt.Errorf("net.psn.client output is not implemented")
func (pc *PSNClient) Output(ctx context.Context, payload any) error {
return fmt.Errorf("psn.client output is not implemented")
}

View File

@@ -0,0 +1,183 @@
//go:build cgo
package module
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/framer"
"github.com/jwetzell/showbridge-go/internal/route"
"go.bug.st/serial"
)
type SerialClient struct {
config config.ModuleConfig
ctx context.Context
router route.RouteIO
Port string
Framer framer.Framer
Mode *serial.Mode
port serial.Port
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "serial.client",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
port, ok := params["port"]
if !ok {
return nil, errors.New("serial.client requires a port parameter")
}
portString, ok := port.(string)
if !ok {
return nil, errors.New("serial.client port must be a string")
}
framingMethod := "RAW"
framingMethodRaw, ok := params["framing"]
if ok {
framingMethodString, ok := framingMethodRaw.(string)
if !ok {
return nil, errors.New("serial.client framing method must be a string")
}
framingMethod = framingMethodString
}
framer := framer.GetFramer(framingMethod)
if framer == nil {
return nil, fmt.Errorf("serial.client unknown framing method: %s", framingMethod)
}
buadRate, ok := params["baudRate"]
if !ok {
return nil, errors.New("serial.client requires a baudRate parameter")
}
baudRateNum, ok := buadRate.(float64)
if !ok {
return nil, errors.New("serial.client baudRate must be a number")
}
mode := serial.Mode{
BaudRate: int(baudRateNum),
}
return &SerialClient{config: config, Port: portString, Framer: framer, Mode: &mode, logger: CreateLogger(config)}, nil
},
})
}
func (sc *SerialClient) Id() string {
return sc.config.Id
}
func (sc *SerialClient) Type() string {
return sc.config.Type
}
func (sc *SerialClient) SetupPort() error {
port, err := serial.Open(sc.Port, sc.Mode)
if err != nil {
return fmt.Errorf("serial.client can't open input port: %s", sc.Port)
}
sc.port = port
return nil
}
func (sc *SerialClient) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("serial.client unable to get router from context")
}
sc.router = router
sc.ctx = ctx
// TODO(jwetzell): shutdown with router.Context properly
go func() {
<-sc.ctx.Done()
sc.logger.Debug("done")
if sc.port != nil {
sc.port.Close()
}
}()
for {
err := sc.SetupPort()
if err != nil {
if sc.ctx.Err() != nil {
sc.logger.Debug("done")
return nil
}
sc.logger.Error("port setup error", "error", err.Error())
time.Sleep(time.Second * 2)
continue
}
buffer := make([]byte, 1024)
select {
case <-sc.ctx.Done():
sc.logger.Debug("done")
return nil
default:
READ:
for {
select {
case <-sc.ctx.Done():
sc.logger.Debug("done")
return nil
default:
byteCount, err := sc.port.Read(buffer)
if err != nil {
sc.Framer.Clear()
break READ
}
if sc.Framer != nil {
if byteCount > 0 {
messages := sc.Framer.Decode(buffer[0:byteCount])
for _, message := range messages {
if sc.router != nil {
sc.router.HandleInput(sc.ctx, sc.Id(), message)
} else {
sc.logger.Error("input received but no router is configured")
}
}
}
}
}
}
}
}
}
func (sc *SerialClient) Output(ctx context.Context, payload any) error {
payloadBytes, ok := payload.([]byte)
if !ok {
return errors.New("serial.client can only ouptut bytes")
}
_, err := sc.port.Write(sc.Framer.Encode(payloadBytes))
return err
}

View File

@@ -0,0 +1,230 @@
package module
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"sync"
"time"
"github.com/emiago/diago"
"github.com/emiago/diago/media"
"github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
"github.com/jwetzell/showbridge-go/internal/route"
)
type SIPCallServer struct {
config config.ModuleConfig
ctx context.Context
router route.RouteIO
IP string
Port int
Transport string
UserAgent string
dg *diago.Diago
logger *slog.Logger
}
type SIPCallMessage struct {
To string
}
type SIPCall struct {
inDialog *diago.DialogServerSession
lock sync.Mutex
}
type sipCallContextKey string
func init() {
RegisterModule(ModuleRegistration{
Type: "sip.call.server",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
portNum := 5060
port, ok := params["port"]
if ok {
specificPortNum, ok := port.(float64)
if !ok {
return nil, errors.New("sip.call.server port must be a number")
}
portNum = int(specificPortNum)
}
ipString := "0.0.0.0"
ip, ok := params["ip"]
if ok {
specificIpString, ok := ip.(string)
if !ok {
return nil, errors.New("sip.call.server ip must be a string")
}
ipString = specificIpString
}
transportString := "udp"
transport, ok := params["transport"]
if ok {
specificTransportString, ok := transport.(string)
if !ok {
return nil, errors.New("sip.call.server transport must be a string")
}
transportString = specificTransportString
}
userAgentString := "showbridge"
userAgent, ok := params["userAgent"]
if ok {
specificTransportString, ok := userAgent.(string)
if !ok {
return nil, errors.New("sip.call.server userAgent must be a string")
}
userAgentString = specificTransportString
}
return &SIPCallServer{config: config, IP: ipString, Port: int(portNum), Transport: transportString, UserAgent: userAgentString, logger: CreateLogger(config)}, nil
},
})
}
func (scs *SIPCallServer) Id() string {
return scs.config.Id
}
func (scs *SIPCallServer) Type() string {
return scs.config.Type
}
func (scs *SIPCallServer) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("sip.call.server unable to get router from context")
}
scs.router = router
scs.ctx = ctx
diagoLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
ua, _ := sipgo.NewUA(
sipgo.WithUserAgent(scs.UserAgent),
sipgo.WithUserAgentTransportLayerOptions(sip.WithTransportLayerLogger(diagoLogger)),
sipgo.WithUserAgentTransactionLayerOptions(sip.WithTransactionLayerLogger(diagoLogger)),
)
defer ua.Close()
sip.SetDefaultLogger(diagoLogger)
media.SetDefaultLogger(diagoLogger)
dg := diago.NewDiago(ua, diago.WithLogger(diagoLogger), diago.WithTransport(
diago.Transport{
Transport: scs.Transport,
BindHost: scs.IP,
BindPort: scs.Port,
},
))
go func() {
dg.Serve(scs.ctx, func(inDialog *diago.DialogServerSession) {
scs.HandleCall(inDialog)
})
}()
scs.dg = dg
<-scs.ctx.Done()
scs.logger.Debug("done")
return nil
}
func (scs *SIPCallServer) HandleCall(inDialog *diago.DialogServerSession) {
inDialog.Trying()
inDialog.Ringing()
inDialog.Answer()
dialogContext := context.WithValue(scs.ctx, sipCallContextKey("call"), &SIPCall{
inDialog: inDialog,
})
scs.router.HandleInput(dialogContext, scs.Id(), SIPCallMessage{
To: inDialog.ToUser(),
})
}
func (scs *SIPCallServer) Output(ctx context.Context, payload any) error {
call, ok := ctx.Value(sipCallContextKey("call")).(*SIPCall)
if !ok {
return errors.New("sip.call.server output must originate from sip.call.server input")
}
gotLock := call.lock.TryLock()
if !gotLock {
return errors.New("sip.call.server call is already locked")
}
if call.inDialog.LoadState() == sip.DialogStateEnded {
return errors.New("sip.call.server inDialog already ended")
}
payloadDTMFResponse, ok := payload.(processor.SipDTMFResponse)
if ok {
dtmfWriter := call.inDialog.AudioWriterDTMF()
time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait))
for i, dtmfRune := range payloadDTMFResponse.Digits {
err := dtmfWriter.WriteDTMF(dtmfRune)
if err != nil {
return fmt.Errorf("sip.dtmf.server error output dtmf digit at index %d", i)
}
}
time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait))
return nil
}
payloadAudioFileResponse, ok := payload.(processor.SipAudioFileResponse)
if ok {
audioFile, err := os.Open(payloadAudioFileResponse.AudioFile)
if err != nil {
return err
}
defer audioFile.Close()
playback, err := call.inDialog.PlaybackCreate()
if err != nil {
return err
}
time.Sleep(time.Millisecond * time.Duration(payloadAudioFileResponse.PreWait))
_, err = playback.Play(audioFile, "audio/wav")
time.Sleep(time.Millisecond * time.Duration(payloadAudioFileResponse.PostWait))
if err != nil {
return err
}
return nil
}
return errors.New("sip.dtmf.server can only output SipDTMFResponse or SipAudioFileResponse")
}

View File

@@ -0,0 +1,245 @@
package module
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"strings"
"sync"
"time"
"github.com/emiago/diago"
"github.com/emiago/diago/media"
"github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
"github.com/jwetzell/showbridge-go/internal/route"
)
type SIPDTMFServer struct {
config config.ModuleConfig
ctx context.Context
router route.RouteIO
IP string
Port int
Transport string
Separator string
logger *slog.Logger
}
type SIPDTMFMessage struct {
To string
Digits string
}
type SIPDTMFCall struct {
inDialog *diago.DialogServerSession
lock sync.Mutex
}
func init() {
RegisterModule(ModuleRegistration{
Type: "sip.dtmf.server",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
portNum := 5060
port, ok := params["port"]
if ok {
specificPortNum, ok := port.(float64)
if !ok {
return nil, errors.New("sip.dtmf.server port must be a number")
}
portNum = int(specificPortNum)
}
ipString := "0.0.0.0"
ip, ok := params["ip"]
if ok {
specificIpString, ok := ip.(string)
if !ok {
return nil, errors.New("sip.dtmf.server ip must be a string")
}
ipString = specificIpString
}
transportString := "udp"
transport, ok := params["transport"]
if ok {
specificTransportString, ok := transport.(string)
if !ok {
return nil, errors.New("sip.dtmf.server transport must be a string")
}
transportString = specificTransportString
}
separator, ok := params["separator"]
if !ok {
return nil, errors.New("sip.dtmf.server requires a separator parameter")
}
separatorString, ok := separator.(string)
if !ok {
return nil, errors.New("sip.dtmf.server separator must be a string")
}
if len(separatorString) != 1 {
return nil, errors.New("sip.dtmf.server separator must be a single character")
}
if !strings.ContainsRune("0123456789*#ABCD", rune(separatorString[0])) {
return nil, errors.New("sip.dtmf.server separator must be a valid DTMF character")
}
return &SIPDTMFServer{config: config, IP: ipString, Port: int(portNum), Transport: transportString, Separator: separatorString, logger: CreateLogger(config)}, nil
},
})
}
func (sds *SIPDTMFServer) Id() string {
return sds.config.Id
}
func (sds *SIPDTMFServer) Type() string {
return sds.config.Type
}
func (sds *SIPDTMFServer) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("sip.dtmf.server unable to get router from context")
}
sds.router = router
sds.ctx = ctx
diagoLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
ua, _ := sipgo.NewUA(
sipgo.WithUserAgentTransportLayerOptions(sip.WithTransportLayerLogger(diagoLogger)),
sipgo.WithUserAgentTransactionLayerOptions(sip.WithTransactionLayerLogger(diagoLogger)),
)
defer ua.Close()
sip.SetDefaultLogger(diagoLogger)
media.SetDefaultLogger(diagoLogger)
dg := diago.NewDiago(ua, diago.WithLogger(diagoLogger), diago.WithTransport(
diago.Transport{
Transport: sds.Transport,
BindHost: sds.IP,
BindPort: sds.Port,
},
))
err := dg.Serve(sds.ctx, func(inDialog *diago.DialogServerSession) {
sds.HandleCall(inDialog)
})
if err != nil {
return err
}
<-sds.ctx.Done()
sds.logger.Debug("done")
return nil
}
func (sds *SIPDTMFServer) HandleCall(inDialog *diago.DialogServerSession) error {
inDialog.Trying()
inDialog.Ringing()
inDialog.Answer()
reader := inDialog.AudioReaderDTMF()
userString := ""
return reader.Listen(func(dtmf rune) error {
if dtmf == rune(sds.Separator[0]) {
if sds.router != nil {
dialogContext := context.WithValue(sds.ctx, sipCallContextKey("call"), &SIPDTMFCall{
inDialog: inDialog,
})
sds.router.HandleInput(dialogContext, sds.Id(), SIPDTMFMessage{
To: inDialog.ToUser(),
Digits: userString,
})
}
userString = ""
} else {
userString += string(dtmf)
}
return nil
}, 5*time.Second)
}
func (sds *SIPDTMFServer) Output(ctx context.Context, payload any) error {
call, ok := ctx.Value(sipCallContextKey("call")).(*SIPDTMFCall)
if !ok {
return errors.New("sip.dtmf.server output must originate from sip.dtmf.server input")
}
gotLock := call.lock.TryLock()
if !gotLock {
return errors.New("sip.dtmf.server call is already locked")
}
if call.inDialog.LoadState() == sip.DialogStateEnded {
return errors.New("sip.dtmf.server inDialog already ended")
}
payloadDTMFResponse, ok := payload.(processor.SipDTMFResponse)
if ok {
dtmfWriter := call.inDialog.AudioWriterDTMF()
time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait))
for i, dtmfRune := range payloadDTMFResponse.Digits {
err := dtmfWriter.WriteDTMF(dtmfRune)
if err != nil {
return fmt.Errorf("sip.dtmf.server error output dtmf digit at index %d", i)
}
}
time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait))
return nil
}
payloadAudioFileResponse, ok := payload.(processor.SipAudioFileResponse)
if ok {
audioFile, err := os.Open(payloadAudioFileResponse.AudioFile)
if err != nil {
return err
}
defer audioFile.Close()
playback, err := call.inDialog.PlaybackCreate()
if err != nil {
return err
}
time.Sleep(time.Millisecond * time.Duration(payloadAudioFileResponse.PreWait))
_, err = playback.Play(audioFile, "audio/wav")
time.Sleep(time.Millisecond * time.Duration(payloadAudioFileResponse.PostWait))
if err != nil {
return err
}
return nil
}
return errors.New("sip.dtmf.server can only output SipDTMFResponse or SipAudioFileResponse")
}

View File

@@ -0,0 +1,179 @@
package module
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/framer"
"github.com/jwetzell/showbridge-go/internal/route"
)
type TCPClient struct {
config config.ModuleConfig
framer framer.Framer
conn *net.TCPConn
ctx context.Context
router route.RouteIO
Addr *net.TCPAddr
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "net.tcp.client",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
host, ok := params["host"]
if !ok {
return nil, errors.New("net.tcp.client requires a host parameter")
}
hostString, ok := host.(string)
if !ok {
return nil, errors.New("net.tcp.client host must be string")
}
port, ok := params["port"]
if !ok {
return nil, errors.New("net.tcp.client requires a port parameter")
}
portNum, ok := port.(float64)
if !ok {
return nil, errors.New("net.tcp.client port must be a number")
}
addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", hostString, uint16(portNum)))
if err != nil {
return nil, err
}
framingMethod := "RAW"
framingMethodRaw, ok := params["framing"]
if ok {
framingMethodString, ok := framingMethodRaw.(string)
if !ok {
return nil, errors.New("net.tcp.client framing method must be a string")
}
framingMethod = framingMethodString
}
framer := framer.GetFramer(framingMethod)
if framer == nil {
return nil, fmt.Errorf("net.tcp.client unknown framing method: %s", framingMethod)
}
return &TCPClient{framer: framer, Addr: addr, config: config, logger: CreateLogger(config)}, nil
},
})
}
func (tc *TCPClient) Id() string {
return tc.config.Id
}
func (tc *TCPClient) Type() string {
return tc.config.Type
}
func (tc *TCPClient) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("net.tcp.client unable to get router from context")
}
tc.router = router
tc.ctx = ctx
// TODO(jwetzell): shutdown with router.Context properly
go func() {
<-tc.ctx.Done()
tc.logger.Debug("done")
if tc.conn != nil {
tc.conn.Close()
}
}()
for {
err := tc.SetupConn()
if err != nil {
if tc.ctx.Err() != nil {
tc.logger.Debug("done")
return nil
}
tc.logger.Error("connection error", "error", err.Error())
time.Sleep(time.Second * 2)
continue
}
buffer := make([]byte, 1024)
select {
case <-tc.ctx.Done():
tc.logger.Debug("done")
return nil
default:
READ:
for {
select {
case <-tc.ctx.Done():
tc.logger.Debug("done")
return nil
default:
byteCount, err := tc.conn.Read(buffer)
if err != nil {
tc.framer.Clear()
break READ
}
if tc.framer != nil {
if byteCount > 0 {
messages := tc.framer.Decode(buffer[0:byteCount])
for _, message := range messages {
if tc.router != nil {
tc.router.HandleInput(tc.ctx, tc.Id(), message)
} else {
tc.logger.Error("input received but no router is configured")
}
}
}
}
}
}
}
}
}
func (tc *TCPClient) SetupConn() error {
client, err := net.DialTCP("tcp", nil, tc.Addr)
tc.conn = client
return err
}
func (tc *TCPClient) Output(ctx context.Context, payload any) error {
// NOTE(jwetzell): not sure how this would occur but
if tc.conn == nil {
err := tc.SetupConn()
if err != nil {
return err
}
}
payloadBytes, ok := payload.([]byte)
if !ok {
return errors.New("net.tcp.client is only able to output bytes")
}
_, err := tc.conn.Write(tc.framer.Encode(payloadBytes))
return err
}

View File

@@ -0,0 +1,229 @@
package module
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"slices"
"sync"
"syscall"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/framer"
"github.com/jwetzell/showbridge-go/internal/route"
)
type TCPServer struct {
config config.ModuleConfig
Addr *net.TCPAddr
Framer framer.Framer
ctx context.Context
router route.RouteIO
quit chan interface{}
wg sync.WaitGroup
connections []*net.TCPConn
connectionsMu sync.RWMutex
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "net.tcp.server",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
port, ok := params["port"]
if !ok {
return nil, errors.New("net.tcp.server requires a port parameter")
}
portNum, ok := port.(float64)
if !ok {
return nil, errors.New("net.tcp.server port must be a number")
}
framingMethod := "RAW"
framingMethodRaw, ok := params["framing"]
if ok {
framingMethodString, ok := framingMethodRaw.(string)
if !ok {
return nil, errors.New("net.tcp.server framing method must be a string")
}
framingMethod = framingMethodString
}
framer := framer.GetFramer(framingMethod)
if framer == nil {
return nil, fmt.Errorf("net.tcp.server unknown framing method: %s", framingMethod)
}
ipString := "0.0.0.0"
ip, ok := params["ip"]
if ok {
specificIpString, ok := ip.(string)
if !ok {
return nil, errors.New("net.tcp.server ip must be a string")
}
ipString = specificIpString
}
addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", ipString, uint16(portNum)))
if err != nil {
return nil, err
}
return &TCPServer{Framer: framer, Addr: addr, config: config, quit: make(chan interface{}), logger: CreateLogger(config)}, nil
},
})
}
func (ts *TCPServer) Id() string {
return ts.config.Id
}
func (ts *TCPServer) Type() string {
return ts.config.Type
}
func (ts *TCPServer) handleClient(client *net.TCPConn) {
ts.connectionsMu.Lock()
ts.connections = append(ts.connections, client)
ts.connectionsMu.Unlock()
ts.logger.Debug("net.tcp.server connection accepted", "remoteAddr", client.RemoteAddr().String())
defer client.Close()
buffer := make([]byte, 1024)
ClientRead:
for {
select {
case <-ts.quit:
return
default:
client.SetDeadline(time.Now().Add(time.Millisecond * 200))
byteCount, err := client.Read(buffer)
if err != nil {
if opErr, ok := err.(*net.OpError); ok {
//NOTE(jwetzell) we hit deadline
if opErr.Timeout() {
continue ClientRead
}
if errors.Is(opErr, syscall.ECONNRESET) {
ts.connectionsMu.Lock()
for i := 0; i < len(ts.connections); i++ {
if ts.connections[i] == client {
ts.connections = slices.Delete(ts.connections, i, i+1)
break
}
}
ts.logger.Debug("connection reset", "remoteAddr", client.RemoteAddr().String())
ts.connectionsMu.Unlock()
}
}
if err.Error() == "EOF" {
ts.connectionsMu.Lock()
for i := 0; i < len(ts.connections); i++ {
if ts.connections[i] == client {
ts.connections = slices.Delete(ts.connections, i, i+1)
break
}
}
ts.logger.Debug("stream ended", "remoteAddr", client.RemoteAddr().String())
ts.connectionsMu.Unlock()
}
return
}
if ts.Framer != nil {
if byteCount > 0 {
messages := ts.Framer.Decode(buffer[0:byteCount])
for _, message := range messages {
if ts.router != nil {
ts.router.HandleInput(ts.ctx, ts.Id(), message)
} else {
ts.logger.Error("input received but no router is configured")
}
}
}
}
}
}
}
func (ts *TCPServer) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("net.tcp.server unable to get router from context")
}
ts.router = router
ts.ctx = ctx
listener, err := net.ListenTCP("tcp", ts.Addr)
if err != nil {
return err
}
ts.wg.Add(1)
go func() {
<-ts.ctx.Done()
close(ts.quit)
listener.Close()
ts.logger.Debug("done")
}()
AcceptLoop:
for {
conn, err := listener.AcceptTCP()
if err != nil {
select {
case <-ts.quit:
break AcceptLoop
default:
ts.logger.Debug("problem with listener", "error", err)
}
} else {
ts.wg.Add(1)
go func() {
ts.handleClient(conn)
ts.wg.Done()
}()
}
}
ts.wg.Done()
ts.wg.Wait()
return nil
}
func (ts *TCPServer) Output(ctx context.Context, payload any) error {
payloadBytes, ok := payload.([]byte)
if !ok {
return errors.New("net.tcp.server is only able to output bytes")
}
ts.connectionsMu.Lock()
errorString := ""
for _, connection := range ts.connections {
_, err := connection.Write(payloadBytes)
if err != nil {
errorString += fmt.Sprintf("%s\n", err.Error())
}
}
ts.connectionsMu.Unlock()
if errorString == "" {
return nil
}
return fmt.Errorf("net.tcp.server error during output: %s", errorString)
}

View File

@@ -0,0 +1,82 @@
package module
import (
"context"
"errors"
"log/slog"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
type TimeInterval struct {
config config.ModuleConfig
Duration uint32
ctx context.Context
router route.RouteIO
ticker *time.Ticker
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "time.interval",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
duration, ok := params["duration"]
if !ok {
return nil, errors.New("time.interval requires a duration parameter")
}
durationNum, ok := duration.(float64)
if !ok {
return nil, errors.New("time.interval duration must be number")
}
return &TimeInterval{Duration: uint32(durationNum), config: config, logger: CreateLogger(config)}, nil
},
})
}
func (i *TimeInterval) Id() string {
return i.config.Id
}
func (i *TimeInterval) Type() string {
return i.config.Type
}
func (i *TimeInterval) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("time.interval unable to get router from context")
}
i.router = router
i.ctx = ctx
ticker := time.NewTicker(time.Millisecond * time.Duration(i.Duration))
i.ticker = ticker
defer ticker.Stop()
for {
select {
case <-i.ctx.Done():
i.logger.Debug("done")
return nil
case <-ticker.C:
if i.router != nil {
i.router.HandleInput(i.ctx, i.Id(), time.Now())
}
}
}
}
func (i *TimeInterval) Output(ctx context.Context, payload any) error {
i.ticker.Reset(time.Millisecond * time.Duration(i.Duration))
return nil
}

View File

@@ -0,0 +1,81 @@
package module
import (
"context"
"errors"
"log/slog"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
type TimeTimer struct {
config config.ModuleConfig
Duration uint32
ctx context.Context
router route.RouteIO
timer *time.Timer
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "time.timer",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
duration, ok := params["duration"]
if !ok {
return nil, errors.New("time.timer requires a duration parameter")
}
durationNum, ok := duration.(float64)
if !ok {
return nil, errors.New("time.timer duration must be a number")
}
return &TimeTimer{Duration: uint32(durationNum), config: config, logger: CreateLogger(config)}, nil
},
})
}
func (t *TimeTimer) Id() string {
return t.config.Id
}
func (t *TimeTimer) Type() string {
return t.config.Type
}
func (t *TimeTimer) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("net.tcp.client unable to get router from context")
}
t.router = router
t.ctx = ctx
t.timer = time.NewTimer(time.Millisecond * time.Duration(t.Duration))
defer t.timer.Stop()
for {
select {
case <-t.ctx.Done():
t.timer.Stop()
t.logger.Debug("done")
return nil
case time := <-t.timer.C:
if t.router != nil {
t.router.HandleInput(t.ctx, t.Id(), time)
}
}
}
}
func (t *TimeTimer) Output(ctx context.Context, payload any) error {
t.timer.Reset(time.Millisecond * time.Duration(t.Duration))
return nil
}

View File

@@ -0,0 +1,114 @@
package module
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
type UDPClient struct {
config config.ModuleConfig
Addr *net.UDPAddr
Port uint16
conn *net.UDPConn
ctx context.Context
router route.RouteIO
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "net.udp.client",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
host, ok := params["host"]
if !ok {
return nil, errors.New("net.udp.client requires a host parameter")
}
hostString, ok := host.(string)
if !ok {
return nil, errors.New("net.udp.client host must be a string")
}
port, ok := params["port"]
if !ok {
return nil, errors.New("net.udp.client requires a port parameter")
}
portNum, ok := port.(float64)
if !ok {
return nil, errors.New("net.udp.client port must be a number")
}
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", hostString, uint16(portNum)))
if err != nil {
return nil, err
}
return &UDPClient{Addr: addr, config: config, logger: CreateLogger(config)}, nil
},
})
}
func (uc *UDPClient) Id() string {
return uc.config.Id
}
func (uc *UDPClient) Type() string {
return uc.config.Type
}
func (uc *UDPClient) SetupConn() error {
client, err := net.DialUDP("udp", nil, uc.Addr)
uc.conn = client
return err
}
func (uc *UDPClient) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("net.udp.client unable to get router from context")
}
uc.router = router
uc.ctx = ctx
err := uc.SetupConn()
if err != nil {
return err
}
<-uc.ctx.Done()
uc.logger.Debug("done")
if uc.conn != nil {
uc.conn.Close()
}
return nil
}
func (uc *UDPClient) Output(ctx context.Context, payload any) error {
payloadBytes, ok := payload.([]byte)
if !ok {
return errors.New("net.udp.client is only able to output bytes")
}
if uc.conn != nil {
_, err := uc.conn.Write(payloadBytes)
if err != nil {
return err
}
} else {
return errors.New("net.udp.client client is not setup")
}
return nil
}

View File

@@ -0,0 +1,132 @@
package module
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
type UDPMulticast struct {
config config.ModuleConfig
conn *net.UDPConn
ctx context.Context
router route.RouteIO
Addr *net.UDPAddr
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "net.udp.multicast",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
ip, ok := params["ip"]
if !ok {
return nil, errors.New("net.udp.multicast requires an ip parameter")
}
ipString, ok := ip.(string)
if !ok {
return nil, errors.New("net.udp.multicast ip must be a string")
}
port, ok := params["port"]
if !ok {
return nil, errors.New("net.udp.multicast requires a port parameter")
}
portNum, ok := port.(float64)
if !ok {
return nil, errors.New("net.udp.multicast port must be a number")
}
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", ipString, uint16(portNum)))
if err != nil {
return nil, err
}
return &UDPMulticast{config: config, Addr: addr, logger: CreateLogger(config)}, nil
},
})
}
func (um *UDPMulticast) Id() string {
return um.config.Id
}
func (um *UDPMulticast) Type() string {
return um.config.Type
}
func (um *UDPMulticast) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("net.udp.multicast unable to get router from context")
}
um.router = router
um.ctx = ctx
client, err := net.ListenMulticastUDP("udp", nil, um.Addr)
if err != nil {
return err
}
defer client.Close()
um.conn = client
buffer := make([]byte, 2048)
for {
select {
case <-um.ctx.Done():
// TODO(jwetzell): cleanup?
um.logger.Debug("done")
return nil
default:
um.conn.SetDeadline(time.Now().Add(time.Millisecond * 200))
numBytes, _, err := um.conn.ReadFromUDP(buffer)
if err != nil {
//NOTE(jwetzell) we hit deadline
if opErr, ok := err.(*net.OpError); ok && opErr.Timeout() {
continue
}
return err
}
if numBytes > 0 {
message := buffer[:numBytes]
if um.router != nil {
um.router.HandleInput(um.ctx, um.Id(), message)
} else {
um.logger.Error("input received but no router is configured")
}
}
}
}
}
func (um *UDPMulticast) Output(ctx context.Context, payload any) error {
payloadBytes, ok := payload.([]byte)
if !ok {
return errors.New("net.udp.multicast can only output bytes")
}
if um.conn == nil {
return errors.New("net.udp.multicast connection is not setup")
}
_, err := um.conn.Write(payloadBytes)
return err
}

View File

@@ -0,0 +1,131 @@
package module
import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"net"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
type UDPServer struct {
Addr *net.UDPAddr
BufferSize int
config config.ModuleConfig
ctx context.Context
router route.RouteIO
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "net.udp.server",
New: func(config config.ModuleConfig) (Module, error) {
params := config.Params
port, ok := params["port"]
if !ok {
return nil, errors.New("net.udp.server requires a port parameter")
}
portNum, ok := port.(float64)
if !ok {
return nil, errors.New("net.udp.server port must be a number")
}
ipString := "0.0.0.0"
ip, ok := params["ip"]
if ok {
specificIpString, ok := ip.(string)
if !ok {
return nil, errors.New("net.udp.server ip must be a string")
}
ipString = specificIpString
}
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", ipString, uint16(portNum)))
if err != nil {
log.Fatalf("error resolving UDP address: %v", err)
}
bufferSizeNum := 2048
bufferSize, ok := params["bufferSize"]
if ok {
bufferSizeFloat, ok := bufferSize.(float64)
if !ok {
return nil, errors.New("net.udp.server bufferSize must be a number")
}
bufferSizeNum = int(bufferSizeFloat)
}
return &UDPServer{Addr: addr, BufferSize: bufferSizeNum, config: config, logger: CreateLogger(config)}, nil
},
})
}
func (us *UDPServer) Id() string {
return us.config.Id
}
func (us *UDPServer) Type() string {
return us.config.Id
}
func (us *UDPServer) Run(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("net.udp.server unable to get router from context")
}
us.router = router
us.ctx = ctx
listener, err := net.ListenUDP("udp", us.Addr)
if err != nil {
return err
}
defer listener.Close()
buffer := make([]byte, us.BufferSize)
for {
select {
case <-us.ctx.Done():
// TODO(jwetzell): cleanup?
us.logger.Debug("done")
return nil
default:
listener.SetDeadline(time.Now().Add(time.Millisecond * 200))
numBytes, _, err := listener.ReadFromUDP(buffer)
if err != nil {
//NOTE(jwetzell) we hit deadline
if opErr, ok := err.(*net.OpError); ok && opErr.Timeout() {
continue
}
return err
}
message := buffer[:numBytes]
if us.router != nil {
us.router.HandleInput(us.ctx, us.Id(), message)
} else {
us.logger.Error("input received but no router is configured")
}
}
}
}
func (us *UDPServer) Output(ctx context.Context, payload any) error {
return errors.New("net.udp.server output is not implemented")
}

View File

@@ -1,29 +0,0 @@
package processing
import (
"context"
"fmt"
"log/slog"
)
type DebugLog struct {
config ProcessorConfig
}
func (dl *DebugLog) Process(ctx context.Context, payload any) (any, error) {
slog.Debug("debug.log", "payload", payload, "payloadType", fmt.Sprintf("%T", payload))
return payload, nil
}
func (dl *DebugLog) Type() string {
return dl.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "debug.log",
New: func(config ProcessorConfig) (Processor, error) {
return &DebugLog{config: config}, nil
},
})
}

View File

@@ -1,39 +0,0 @@
package processing
import (
"context"
"fmt"
"strconv"
)
type FloatParse struct {
config ProcessorConfig
}
func (fp *FloatParse) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string)
if !ok {
return nil, fmt.Errorf("float.parse processor only accepts a string")
}
// TODO(jwetzell): make bitSize configurable
payloadFloat, err := strconv.ParseFloat(payloadString, 64)
if err != nil {
return nil, err
}
return payloadFloat, nil
}
func (fp *FloatParse) Type() string {
return fp.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "float.parse",
New: func(config ProcessorConfig) (Processor, error) {
return &FloatParse{config: config}, nil
},
})
}

View File

@@ -1,39 +0,0 @@
package processing
import (
"context"
"fmt"
"strconv"
)
type IntParse struct {
config ProcessorConfig
}
func (ip *IntParse) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string)
if !ok {
return nil, fmt.Errorf("int.parse processor only accepts a string")
}
// TODO(jwetzell): make base and bitSize configurable
payloadInt, err := strconv.ParseInt(payloadString, 10, 64)
if err != nil {
return nil, err
}
return payloadInt, nil
}
func (ip *IntParse) Type() string {
return ip.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "int.parse",
New: func(config ProcessorConfig) (Processor, error) {
return &IntParse{config: config}, nil
},
})
}

View File

@@ -1,90 +0,0 @@
package processing
import (
"context"
"fmt"
)
type MQTTMessage struct {
Topic string
QoS byte
Payload any
Retained bool
}
type MQTTMessageCreate struct {
config ProcessorConfig
Topic string
QoS byte
Retained bool
Payload any
}
func (mmc *MQTTMessageCreate) Process(ctx context.Context, payload any) (any, error) {
message := MQTTMessage{
Topic: mmc.Topic,
QoS: mmc.QoS,
Retained: mmc.Retained,
Payload: mmc.Payload,
}
return message, nil
}
func (mmc *MQTTMessageCreate) Type() string {
return mmc.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "mqtt.message.create",
New: func(config ProcessorConfig) (Processor, error) {
params := config.Params
topic, ok := params["topic"]
if !ok {
return nil, fmt.Errorf("mqtt.message.create requires an topic parameter")
}
topicString, ok := topic.(string)
if !ok {
return nil, fmt.Errorf("mqtt.message.create topic must be a string")
}
qos, ok := params["qos"]
if !ok {
return nil, fmt.Errorf("mqtt.message.create requires an qos parameter")
}
qosByte, ok := qos.(float64)
if !ok {
return nil, fmt.Errorf("mqtt.message.create qos must be a number")
}
retained, ok := params["retained"]
if !ok {
return nil, fmt.Errorf("mqtt.message.create requires an retained parameter")
}
retainedBool, ok := retained.(bool)
if !ok {
return nil, fmt.Errorf("mqtt.message.create retained must be a boolean")
}
//TODO(jwetzell): convert payload into []byte or string for sending
payload, ok := params["payload"]
if !ok {
return nil, fmt.Errorf("mqtt.message.create requires an payload parameter")
}
return &MQTTMessageCreate{config: config, Topic: topicString, QoS: byte(qosByte), Retained: retainedBool, Payload: payload}, nil
},
})
}

View File

@@ -1,36 +0,0 @@
package processing
import (
"context"
"fmt"
osc "github.com/jwetzell/osc-go"
)
type OSCMessageEncode struct {
config ProcessorConfig
}
func (o *OSCMessageEncode) Process(ctx context.Context, payload any) (any, error) {
payloadMessage, ok := payload.(osc.OSCMessage)
if !ok {
return nil, fmt.Errorf("osc.message.encode processor only accepts an OSCMessage")
}
bytes := payloadMessage.ToBytes()
return bytes, nil
}
func (o *OSCMessageEncode) Type() string {
return o.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "osc.message.encode",
New: func(config ProcessorConfig) (Processor, error) {
return &OSCMessageEncode{config: config}, nil
},
})
}

View File

@@ -1,41 +0,0 @@
package processing_test
import (
"testing"
"github.com/jwetzell/showbridge-go/internal/processing"
)
func TestGoodStringDecode(t *testing.T) {
stringDecoder := processing.StringDecode{}
tests := []struct {
processor processing.Processor
name string
payload any
expected string
}{
{
processor: &stringDecoder,
name: "hello",
payload: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f},
expected: "hello",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload)
gotString, ok := got.(string)
if !ok {
t.Errorf("string.decode returned a %T payload: %s", got, got)
}
if err != nil {
t.Errorf("string.decode failed: %s", err)
}
if gotString != test.expected {
t.Errorf("string.decode got %s, expected %s", got, test.expected)
}
})
}
}

View File

@@ -1,42 +0,0 @@
package processing_test
import (
"slices"
"testing"
"github.com/jwetzell/showbridge-go/internal/processing"
)
func TestGoodStringEncode(t *testing.T) {
stringEncoder := processing.StringEncode{}
tests := []struct {
processor processing.Processor
name string
payload any
expected []byte
}{
{
processor: &stringEncoder,
name: "hello",
payload: "hello",
expected: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload)
gotBytes, ok := got.([]byte)
if !ok {
t.Errorf("string.encode returned a %T payload: %s", got, got)
}
if err != nil {
t.Errorf("string.encode failed: %s", err)
}
if !slices.Equal(gotBytes, test.expected) {
t.Errorf("string.encode got %s, expected %s", got, test.expected)
}
})
}
}

View File

@@ -1,59 +0,0 @@
package processing
import (
"context"
"fmt"
"regexp"
)
type StringFilter struct {
config ProcessorConfig
Pattern *regexp.Regexp
}
func (se *StringFilter) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string)
if !ok {
return nil, fmt.Errorf("string.filter processor only accepts a string")
}
if !se.Pattern.MatchString(payloadString) {
return nil, nil
}
return payloadString, nil
}
func (se *StringFilter) Type() string {
return se.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "string.filter",
New: func(config ProcessorConfig) (Processor, error) {
params := config.Params
pattern, ok := params["pattern"]
if !ok {
return nil, fmt.Errorf("http.request.filter requires an pattern parameter")
}
patternString, ok := pattern.(string)
if !ok {
return nil, fmt.Errorf("http.request.filter pattern must be a string")
}
patternRegexp, err := regexp.Compile(fmt.Sprintf("^%s$", patternString))
if err != nil {
return nil, err
}
return &StringFilter{config: config, Pattern: patternRegexp}, nil
},
})
}

View File

@@ -1,51 +0,0 @@
package processing
import (
"context"
"fmt"
"strings"
)
type StringSplit struct {
config ProcessorConfig
Separator string
}
func (se *StringSplit) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string)
if !ok {
return nil, fmt.Errorf("string.split only accepts a string")
}
payloadParts := strings.Split(payloadString, se.Separator)
return payloadParts, nil
}
func (se *StringSplit) Type() string {
return se.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "string.split",
New: func(config ProcessorConfig) (Processor, error) {
params := config.Params
separator, ok := params["separator"]
if !ok {
return nil, fmt.Errorf("string.split requires a separator")
}
separatorString, ok := separator.(string)
if !ok {
return nil, fmt.Errorf("string.split separator must be a string")
}
return &StringSplit{config: config, Separator: separatorString}, nil
},
})
}

View File

@@ -1,39 +0,0 @@
package processing
import (
"context"
"fmt"
"strconv"
)
type UintParse struct {
config ProcessorConfig
}
func (up *UintParse) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string)
if !ok {
return nil, fmt.Errorf("uint.parse processor only accepts a string")
}
// TODO(jwetzell): make base and bitSize configurable
payloadUint, err := strconv.ParseUint(payloadString, 10, 64)
if err != nil {
return nil, err
}
return payloadUint, nil
}
func (up *UintParse) Type() string {
return up.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "uint.parse",
New: func(config ProcessorConfig) (Processor, error) {
return &UintParse{config: config}, nil
},
})
}

View File

@@ -0,0 +1,42 @@
package processor
import (
"context"
"fmt"
"github.com/jwetzell/artnet-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type ArtNetPacketDecode struct {
config config.ProcessorConfig
}
func (apd *ArtNetPacketDecode) Process(ctx context.Context, payload any) (any, error) {
payloadBytes, ok := payload.([]byte)
if !ok {
return nil, fmt.Errorf("artnet.packet.decode processor only accepts a []byte")
}
payloadMessage, err := artnet.Decode(payloadBytes)
if err != nil {
return nil, err
}
return payloadMessage, nil
}
func (apd *ArtNetPacketDecode) Type() string {
return apd.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "artnet.packet.decode",
New: func(config config.ProcessorConfig) (Processor, error) {
return &ArtNetPacketDecode{config: config}, nil
},
})
}

View File

@@ -0,0 +1,41 @@
package processor
import (
"context"
"fmt"
"github.com/jwetzell/artnet-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type ArtNetPacketEncode struct {
config config.ProcessorConfig
}
func (ape *ArtNetPacketEncode) Process(ctx context.Context, payload any) (any, error) {
payloadPacket, ok := payload.(artnet.ArtNetPacket)
if !ok {
return nil, fmt.Errorf("artnet.packet.encode processor only accepts an ArtNetPacket")
}
payloadBytes, err := payloadPacket.MarshalBinary()
if err != nil {
return nil, err
}
return payloadBytes, nil
}
func (ape *ArtNetPacketEncode) Type() string {
return ape.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "artnet.packet.encode",
New: func(config config.ProcessorConfig) (Processor, error) {
return &ArtNetPacketEncode{config: config}, nil
},
})
}

View File

@@ -0,0 +1,52 @@
package processor
import (
"context"
"fmt"
"github.com/jwetzell/artnet-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type ArtNetPacketFilter struct {
config config.ProcessorConfig
OpCode uint16
}
func (apf *ArtNetPacketFilter) Process(ctx context.Context, payload any) (any, error) {
payloadPacket, ok := payload.(artnet.ArtNetPacket)
if !ok {
return nil, fmt.Errorf("artnet.packet.filter processor only accepts an ArtNetPacket")
}
if payloadPacket.GetOpCode() != apf.OpCode {
return nil, nil
}
return payloadPacket, nil
}
func (apf *ArtNetPacketFilter) Type() string {
return apf.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "artnet.packet.filter",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
opCode, ok := params["opCode"]
if !ok {
return nil, fmt.Errorf("artnet.packet.filter requires an opCode parameter")
}
opCodeNum, ok := opCode.(float64)
if !ok {
return nil, fmt.Errorf("artnet.packet.filter opCode must be a number")
}
return &ArtNetPacketFilter{config: config, OpCode: uint16(opCodeNum)}, nil
},
})
}

View File

@@ -0,0 +1,32 @@
package processor
import (
"context"
"fmt"
"log/slog"
"github.com/jwetzell/showbridge-go/internal/config"
)
type DebugLog struct {
config config.ProcessorConfig
logger *slog.Logger
}
func (dl *DebugLog) Process(ctx context.Context, payload any) (any, error) {
dl.logger.Debug("", "payload", payload, "payloadType", fmt.Sprintf("%T", payload))
return payload, nil
}
func (dl *DebugLog) Type() string {
return dl.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "debug.log",
New: func(config config.ProcessorConfig) (Processor, error) {
return &DebugLog{config: config, logger: slog.Default().With("component", "processor", "type", config.Type)}, nil
},
})
}

View File

@@ -0,0 +1,39 @@
package processor_test
import (
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestDebugLogFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["debug.log"]
if !ok {
t.Fatalf("debug.log processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "debug.log",
})
if err != nil {
t.Fatalf("failed to create debug.log processor: %s", err)
}
if processorInstance.Type() != "debug.log" {
t.Fatalf("debug.log processor has wrong type: %s", processorInstance.Type())
}
payload := "test"
expected := "test"
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("debug.log processing failed: %s", err)
}
if got != expected {
t.Fatalf("debug.log got %+v, expected %+v", got, expected)
}
}

View File

@@ -0,0 +1,53 @@
package processor
import (
"context"
"errors"
"strconv"
"github.com/jwetzell/showbridge-go/internal/config"
)
type FloatParse struct {
BitSize int
config config.ProcessorConfig
}
func (fp *FloatParse) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string)
if !ok {
return nil, errors.New("float.parse processor only accepts a string")
}
payloadFloat, err := strconv.ParseFloat(payloadString, fp.BitSize)
if err != nil {
return nil, err
}
return payloadFloat, nil
}
func (fp *FloatParse) Type() string {
return fp.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "float.parse",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
bitSizeNum := 64
bitSize, ok := params["bitSize"]
if ok {
bitSizeFloat, ok := bitSize.(float64)
if !ok {
return nil, errors.New("float.parse bitSize must be a number")
}
bitSizeNum = int(bitSizeFloat)
}
return &FloatParse{config: config, BitSize: bitSizeNum}, nil
},
})
}

View File

@@ -0,0 +1,173 @@
package processor_test
import (
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestFloatParseFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["float.parse"]
if !ok {
t.Fatalf("float.parse processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "float.parse",
})
if err != nil {
t.Fatalf("failed to create float.parse processor: %s", err)
}
if processorInstance.Type() != "float.parse" {
t.Fatalf("float.parse processor has wrong type: %s", processorInstance.Type())
}
}
func TestFloatParseBadConfigBitSizeString(t *testing.T) {
registration, ok := processor.ProcessorRegistry["float.parse"]
if !ok {
t.Fatalf("float.parse processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "float.parse",
Params: map[string]any{
"bitSize": "64",
},
})
if err == nil {
t.Fatalf("float.parse should have returned an error for bad bitSize config")
}
}
func TestFloatParseGoodConfig(t *testing.T) {
registration, ok := processor.ProcessorRegistry["float.parse"]
if !ok {
t.Fatalf("float.parse processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "float.parse",
Params: map[string]any{
"bitSize": 64.0,
},
})
if err != nil {
t.Fatalf("float.parse should have created processor but got error: %s", err)
}
payload := "12345.0"
expected := float64(12345.0)
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("float.parse processing failed: %s", err)
}
gotFloat, ok := got.(float64)
if !ok {
t.Fatalf("float.parse returned a %T payload: %s", got, got)
}
if gotFloat != expected {
t.Fatalf("float.parse got %f, expected %f", gotFloat, expected)
}
}
func TestGoodFloatParse(t *testing.T) {
floatParser := processor.FloatParse{}
tests := []struct {
processor processor.Processor
name string
payload any
bitSize int
expected float64
}{
{
name: "positive number",
payload: "12345.67",
bitSize: 64,
expected: 12345.67,
},
{
name: "negative number",
payload: "-12345.67",
bitSize: 64,
expected: -12345.67,
},
{
name: "zero",
payload: "0",
bitSize: 64,
expected: 0,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := floatParser.Process(t.Context(), test.payload)
gotFloat, ok := got.(float64)
if !ok {
t.Fatalf("float.parse returned a %T payload: %s", got, got)
}
if err != nil {
t.Fatalf("float.parse failed: %s", err)
}
if gotFloat != test.expected {
t.Fatalf("float.parse got %f, expected %f", gotFloat, test.expected)
}
})
}
}
func TestBadFloatParse(t *testing.T) {
tests := []struct {
processor processor.Processor
name string
payload any
bitSize int
errorString string
}{
{
name: "non-string input",
payload: []byte{0x01},
bitSize: 64,
errorString: "float.parse processor only accepts a string",
},
{
name: "not float string",
payload: "abcd",
bitSize: 64,
errorString: "strconv.ParseFloat: parsing \"abcd\": invalid syntax",
},
{
name: "bit size overflow",
payload: "1.79e+64",
bitSize: 32,
errorString: "strconv.ParseFloat: parsing \"1.79e+64\": value out of range",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
floatParser := processor.FloatParse{
BitSize: test.bitSize,
}
got, err := floatParser.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("float.parse expected to fail but succeeded, got: %v", got)
}
if err.Error() != test.errorString {
t.Fatalf("float.parse got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -1,17 +1,18 @@
package processing
package processor
import (
"bytes"
"context"
"fmt"
"errors"
"strconv"
"text/template"
freeD "github.com/jwetzell/free-d-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type FreeDCreate struct {
config ProcessorConfig
config config.ProcessorConfig
Id *template.Template
Pan *template.Template
Tilt *template.Template
@@ -51,6 +52,10 @@ func (fc *FreeDCreate) Process(ctx context.Context, payload any) (any, error) {
panNum, err := strconv.ParseFloat(panString, 32)
if err != nil {
return nil, err
}
var tiltBuffer bytes.Buffer
err = fc.Tilt.Execute(&tiltBuffer, payload)
@@ -62,6 +67,10 @@ func (fc *FreeDCreate) Process(ctx context.Context, payload any) (any, error) {
tiltNum, err := strconv.ParseFloat(tiltString, 32)
if err != nil {
return nil, err
}
var rollBuffer bytes.Buffer
err = fc.Tilt.Execute(&rollBuffer, payload)
@@ -77,10 +86,6 @@ func (fc *FreeDCreate) Process(ctx context.Context, payload any) (any, error) {
return nil, err
}
if err != nil {
return nil, err
}
var posXBuffer bytes.Buffer
err = fc.PosX.Execute(&posXBuffer, payload)
@@ -178,20 +183,20 @@ func (fc *FreeDCreate) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "freed.create",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
// TODO(jwetzell): make some params optional
params := config.Params
id, ok := params["id"]
if !ok {
return nil, fmt.Errorf("freed.create requires an id parameter")
return nil, errors.New("freed.create requires an id parameter")
}
idString, ok := id.(string)
if !ok {
return nil, fmt.Errorf("freed.create id must be a string")
return nil, errors.New("freed.create id must be a string")
}
idTemplate, err := template.New("id").Parse(idString)
@@ -203,13 +208,13 @@ func init() {
pan, ok := params["pan"]
if !ok {
return nil, fmt.Errorf("freed.create requires an pan parameter")
return nil, errors.New("freed.create requires a pan parameter")
}
panString, ok := pan.(string)
if !ok {
return nil, fmt.Errorf("freed.create pan must be a string")
return nil, errors.New("freed.create pan must be a string")
}
panTemplate, err := template.New("pan").Parse(panString)
@@ -217,13 +222,13 @@ func init() {
tilt, ok := params["tilt"]
if !ok {
return nil, fmt.Errorf("freed.create requires an tilt parameter")
return nil, errors.New("freed.create requires a tilt parameter")
}
tiltString, ok := tilt.(string)
if !ok {
return nil, fmt.Errorf("freed.create tilt must be a string")
return nil, errors.New("freed.create tilt must be a string")
}
tiltTemplate, err := template.New("tilt").Parse(tiltString)
@@ -231,13 +236,13 @@ func init() {
roll, ok := params["roll"]
if !ok {
return nil, fmt.Errorf("freed.create requires an roll parameter")
return nil, errors.New("freed.create requires a roll parameter")
}
rollString, ok := roll.(string)
if !ok {
return nil, fmt.Errorf("freed.create roll must be a string")
return nil, errors.New("freed.create roll must be a string")
}
rollTemplate, err := template.New("roll").Parse(rollString)
@@ -249,13 +254,13 @@ func init() {
posX, ok := params["posX"]
if !ok {
return nil, fmt.Errorf("freed.create requires a posX parameter")
return nil, errors.New("freed.create requires a posX parameter")
}
posXString, ok := posX.(string)
if !ok {
return nil, fmt.Errorf("freed.create posX must be a string")
return nil, errors.New("freed.create posX must be a string")
}
posXTemplate, err := template.New("posX").Parse(posXString)
@@ -267,13 +272,13 @@ func init() {
posY, ok := params["posY"]
if !ok {
return nil, fmt.Errorf("freed.create requires a posY parameter")
return nil, errors.New("freed.create requires a posY parameter")
}
posYString, ok := posY.(string)
if !ok {
return nil, fmt.Errorf("freed.create posY must be a string")
return nil, errors.New("freed.create posY must be a string")
}
posYTemplate, err := template.New("posY").Parse(posYString)
@@ -285,13 +290,13 @@ func init() {
posZ, ok := params["posZ"]
if !ok {
return nil, fmt.Errorf("freed.create requires a posZ parameter")
return nil, errors.New("freed.create requires a posZ parameter")
}
posZString, ok := posZ.(string)
if !ok {
return nil, fmt.Errorf("freed.create posZ must be a string")
return nil, errors.New("freed.create posZ must be a string")
}
posZTemplate, err := template.New("posZ").Parse(posZString)
@@ -303,13 +308,13 @@ func init() {
zoom, ok := params["zoom"]
if !ok {
return nil, fmt.Errorf("freed.create requires an zoom parameter")
return nil, errors.New("freed.create requires a zoom parameter")
}
zoomString, ok := zoom.(string)
if !ok {
return nil, fmt.Errorf("freed.create zoom must be a string")
return nil, errors.New("freed.create zoom must be a string")
}
zoomTemplate, err := template.New("zoom").Parse(zoomString)
@@ -317,13 +322,13 @@ func init() {
focus, ok := params["focus"]
if !ok {
return nil, fmt.Errorf("freed.create requires an focus parameter")
return nil, errors.New("freed.create requires a focus parameter")
}
focusString, ok := focus.(string)
if !ok {
return nil, fmt.Errorf("freed.create focus must be a string")
return nil, errors.New("freed.create focus must be a string")
}
focusTemplate, err := template.New("focus").Parse(focusString)

View File

@@ -1,27 +1,27 @@
package processing
package processor
import (
"context"
"fmt"
"log/slog"
"errors"
freeD "github.com/jwetzell/free-d-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type FreeDDecode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (fdd *FreeDDecode) Process(ctx context.Context, payload any) (any, error) {
payloadBytes, ok := payload.([]byte)
if !ok {
return nil, fmt.Errorf("freed.decode processor only accepts a []byte")
return nil, errors.New("freed.decode processor only accepts a []byte")
}
payloadMessage, err := freeD.Decode(payloadBytes)
if err != nil {
slog.Error("error decoding", "err", err)
return nil, err
}
return payloadMessage, nil
}
@@ -33,7 +33,7 @@ func (fdd *FreeDDecode) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "freed.decode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &FreeDDecode{config: config}, nil
},
})

View File

@@ -1,21 +1,22 @@
package processing
package processor
import (
"context"
"fmt"
"errors"
freeD "github.com/jwetzell/free-d-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type FreeDEncode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (fde *FreeDEncode) Process(ctx context.Context, payload any) (any, error) {
payloadPosition, ok := payload.(freeD.FreeDPosition)
if !ok {
return nil, fmt.Errorf("freed.decode processor only accepts a FreeDEncode")
return nil, errors.New("freed.decode processor only accepts a FreeDEncode")
}
payloadBytes := freeD.Encode(payloadPosition)
@@ -29,7 +30,7 @@ func (fde *FreeDEncode) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "freed.encode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &FreeDEncode{config: config}, nil
},
})

View File

@@ -1,23 +1,25 @@
package processing
package processor
import (
"bytes"
"context"
"fmt"
"errors"
"net/http"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
)
type HTTPRequestCreate struct {
config ProcessorConfig
config config.ProcessorConfig
Method string
URL *template.Template
}
func (hre *HTTPRequestCreate) Process(ctx context.Context, payload any) (any, error) {
func (hrc *HTTPRequestCreate) Process(ctx context.Context, payload any) (any, error) {
var urlBuffer bytes.Buffer
err := hre.URL.Execute(&urlBuffer, payload)
err := hrc.URL.Execute(&urlBuffer, payload)
if err != nil {
return nil, err
@@ -26,7 +28,7 @@ func (hre *HTTPRequestCreate) Process(ctx context.Context, payload any) (any, er
urlString := urlBuffer.String()
//TODO(jwetzell): support body
request, err := http.NewRequest(hre.Method, urlString, bytes.NewBuffer([]byte{}))
request, err := http.NewRequest(hrc.Method, urlString, bytes.NewBuffer([]byte{}))
if err != nil {
return nil, err
@@ -35,38 +37,38 @@ func (hre *HTTPRequestCreate) Process(ctx context.Context, payload any) (any, er
return request, nil
}
func (hre *HTTPRequestCreate) Type() string {
return hre.config.Type
func (hrc *HTTPRequestCreate) Type() string {
return hrc.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "http.request.create",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
method, ok := params["method"]
if !ok {
return nil, fmt.Errorf("http.request.create requires an method parameter")
return nil, errors.New("http.request.create requires a method parameter")
}
methodString, ok := method.(string)
if !ok {
return nil, fmt.Errorf("http.request.create url must be a string")
return nil, errors.New("http.request.create url must be a string")
}
url, ok := params["url"]
if !ok {
return nil, fmt.Errorf("http.request.create requires an url parameter")
return nil, errors.New("http.request.create requires a url parameter")
}
urlString, ok := url.(string)
if !ok {
return nil, fmt.Errorf("http.request.create url must be a string")
return nil, errors.New("http.request.create url must be a string")
}
urlTemplate, err := template.New("url").Parse(urlString)

View File

@@ -1,21 +1,23 @@
package processing
package processor
import (
"context"
"fmt"
"errors"
"io"
"net/http"
"github.com/jwetzell/showbridge-go/internal/config"
)
type HTTPRequestEncode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (hre *HTTPRequestEncode) Process(ctx context.Context, payload any) (any, error) {
payloadRequest, ok := payload.(*http.Request)
if !ok {
return nil, fmt.Errorf("http.request.encode processor only accepts an http.Request")
return nil, errors.New("http.request.encode processor only accepts an http.Request")
}
bytes, err := io.ReadAll(payloadRequest.Body)
@@ -33,7 +35,7 @@ func (hre *HTTPRequestEncode) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "http.request.encode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &HTTPRequestEncode{config: config}, nil
},
})

View File

@@ -1,14 +1,17 @@
package processing
package processor
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"github.com/jwetzell/showbridge-go/internal/config"
)
type HTTPRequestFilter struct {
config ProcessorConfig
config config.ProcessorConfig
Path *regexp.Regexp
Method string
}
@@ -18,7 +21,7 @@ func (hrf *HTTPRequestFilter) Process(ctx context.Context, payload any) (any, er
payloadRequest, ok := payload.(*http.Request)
if !ok {
return nil, fmt.Errorf("http.request.filter can only operate on http.Request payloads")
return nil, errors.New("http.request.filter can only operate on http.Request payloads")
}
if hrf.Method != "" {
@@ -41,18 +44,18 @@ func (hrf *HTTPRequestFilter) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "http.request.filter",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
path, ok := params["path"]
if !ok {
return nil, fmt.Errorf("http.request.filter requires an path parameter")
return nil, errors.New("http.request.filter requires a path parameter")
}
pathString, ok := path.(string)
if !ok {
return nil, fmt.Errorf("http.request.filter path must be a string")
return nil, errors.New("http.request.filter path must be a string")
}
pathRegexp, err := regexp.Compile(fmt.Sprintf("^%s$", pathString))
@@ -67,7 +70,7 @@ func init() {
methodString, ok := method.(string)
if !ok {
return nil, fmt.Errorf("http.request.filter method must be a string")
return nil, errors.New("http.request.filter method must be a string")
}
return &HTTPRequestFilter{config: config, Path: pathRegexp, Method: methodString}, nil
}

View File

@@ -0,0 +1,81 @@
package processor
import (
"bytes"
"context"
"errors"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
)
type HTTPResponseCreate struct {
Status int
BodyTmpl *template.Template
config config.ProcessorConfig
}
type HTTPResponse struct {
Status int
Body []byte
}
func (hre *HTTPResponseCreate) Process(ctx context.Context, payload any) (any, error) {
var bodyBuffer bytes.Buffer
err := hre.BodyTmpl.Execute(&bodyBuffer, payload)
if err != nil {
return nil, err
}
return HTTPResponse{
Status: hre.Status,
Body: bodyBuffer.Bytes(),
}, nil
}
func (hre *HTTPResponseCreate) Type() string {
return hre.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "http.response.create",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
status, ok := params["status"]
if !ok {
return nil, errors.New("http.response.create requires a status parameter")
}
statusNum, ok := status.(float64)
if !ok {
return nil, errors.New("http.response.create status must be a number")
}
bodyTmpl, ok := params["bodyTemplate"]
if !ok {
return nil, errors.New("http.response.create requires a bodyTemplate parameter")
}
bodyTemplateString, ok := bodyTmpl.(string)
if !ok {
return nil, errors.New("http.response.create bodyTemplate must be a string")
}
bodyTemplate, err := template.New("body").Parse(bodyTemplateString)
if err != nil {
return nil, err
}
// TODO(jwetzell): support other body kind (direct bytes from input, from file?)
return &HTTPResponseCreate{config: config, Status: int(statusNum), BodyTmpl: bodyTemplate}, nil
},
})
}

View File

@@ -1,21 +1,23 @@
package processing
package processor
import (
"context"
"fmt"
"errors"
"io"
"net/http"
"github.com/jwetzell/showbridge-go/internal/config"
)
type HTTPResponseEncode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (hre *HTTPResponseEncode) Process(ctx context.Context, payload any) (any, error) {
payloadResponse, ok := payload.(*http.Response)
if !ok {
return nil, fmt.Errorf("http.response.encode processor only accepts an http.Response")
return nil, errors.New("http.response.encode processor only accepts an http.Response")
}
defer payloadResponse.Body.Close()
@@ -34,7 +36,7 @@ func (hre *HTTPResponseEncode) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "http.response.encode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &HTTPResponseEncode{config: config}, nil
},
})

View File

@@ -0,0 +1,67 @@
package processor
import (
"context"
"errors"
"strconv"
"github.com/jwetzell/showbridge-go/internal/config"
)
type IntParse struct {
Base int
BitSize int
config config.ProcessorConfig
}
func (ip *IntParse) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string)
if !ok {
return nil, errors.New("int.parse processor only accepts a string")
}
payloadInt, err := strconv.ParseInt(payloadString, ip.Base, ip.BitSize)
if err != nil {
return nil, err
}
return payloadInt, nil
}
func (ip *IntParse) Type() string {
return ip.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "int.parse",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
baseNum := 10
base, ok := params["base"]
if ok {
baseFloat, ok := base.(float64)
if !ok {
return nil, errors.New("int.parse base must be a number")
}
baseNum = int(baseFloat)
}
bitSizeNum := 64
bitSize, ok := params["bitSize"]
if ok {
bitSizeFloat, ok := bitSize.(float64)
if !ok {
return nil, errors.New("int.parse bitSize must be a number")
}
bitSizeNum = int(bitSizeFloat)
}
return &IntParse{config: config, Base: baseNum, BitSize: bitSizeNum}, nil
},
})
}

View File

@@ -0,0 +1,219 @@
package processor_test
import (
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestIntParseFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["int.parse"]
if !ok {
t.Fatalf("int.parse processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "int.parse",
})
if err != nil {
t.Fatalf("failed to create int.parse processor: %s", err)
}
if processorInstance.Type() != "int.parse" {
t.Fatalf("int.parse processor has wrong type: %s", processorInstance.Type())
}
}
func TestIntParseBadConfigBaseString(t *testing.T) {
registration, ok := processor.ProcessorRegistry["int.parse"]
if !ok {
t.Fatalf("int.parse processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "int.parse",
Params: map[string]any{
"base": "10",
},
})
if err == nil {
t.Fatalf("int.parse should have returned an error for bad base config")
}
}
func TestIntParseBadConfigBitSizeString(t *testing.T) {
registration, ok := processor.ProcessorRegistry["int.parse"]
if !ok {
t.Fatalf("int.parse processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "int.parse",
Params: map[string]any{
"bitSize": "64",
},
})
if err == nil {
t.Fatalf("int.parse should have returned an error for bad bitSize config")
}
}
func TestIntParseGoodConfig(t *testing.T) {
registration, ok := processor.ProcessorRegistry["int.parse"]
if !ok {
t.Fatalf("int.parse processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "int.parse",
Params: map[string]any{
"base": 10.0,
"bitSize": 64.0,
},
})
if err != nil {
t.Fatalf("int.parse should have created processor but got error: %s", err)
}
payload := "12345"
expected := int64(12345)
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("int.parse processing failed: %s", err)
}
gotInt, ok := got.(int64)
if !ok {
t.Fatalf("int.parse returned a %T payload: %s", got, got)
}
if gotInt != expected {
t.Fatalf("int.parse got %d, expected %d", gotInt, expected)
}
}
func TestGoodIntParse(t *testing.T) {
tests := []struct {
processor processor.Processor
name string
payload any
expected int64
base int
bitSize int
}{
{
name: "positive number",
payload: "12345",
expected: 12345,
base: 10,
bitSize: 64,
},
{
name: "negative number",
payload: "-12345",
expected: -12345,
base: 10,
bitSize: 64,
},
{
name: "zero",
payload: "0",
expected: 0,
base: 10,
bitSize: 64,
},
{
name: "binary",
payload: "1010101",
expected: 85,
base: 2,
bitSize: 64,
},
{
name: "hex",
payload: "15F",
expected: 351,
base: 16,
bitSize: 64,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
intParser := processor.IntParse{
Base: test.base,
BitSize: test.bitSize,
}
got, err := intParser.Process(t.Context(), test.payload)
gotInt, ok := got.(int64)
if !ok {
t.Fatalf("int.parse returned a %T payload: %s", got, got)
}
if err != nil {
t.Fatalf("int.parse failed: %s", err)
}
if gotInt != test.expected {
t.Fatalf("int.parse got %d, expected %d", gotInt, test.expected)
}
})
}
}
func TestBadIntParse(t *testing.T) {
tests := []struct {
processor processor.Processor
name string
payload any
base int
bitSize int
errorString string
}{
{
name: "non-string input",
payload: []byte{0x01},
base: 10,
bitSize: 64,
errorString: "int.parse processor only accepts a string",
},
{
name: "not int string",
payload: "123.46",
base: 10,
bitSize: 64,
errorString: "strconv.ParseInt: parsing \"123.46\": invalid syntax",
},
{
name: "bit overflow",
payload: "12345678901234567890",
base: 10,
bitSize: 32,
errorString: "strconv.ParseInt: parsing \"12345678901234567890\": value out of range",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
intParser := processor.IntParse{
Base: test.base,
BitSize: test.bitSize,
}
got, err := intParser.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("int.parse expected to fail but succeeded, got: %v", got)
}
if err.Error() != test.errorString {
t.Fatalf("int.parse got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -0,0 +1,61 @@
package processor
import (
"context"
"errors"
"math/rand/v2"
"github.com/jwetzell/showbridge-go/internal/config"
)
type IntRandom struct {
Min int
Max int
config config.ProcessorConfig
}
func (up *IntRandom) Process(ctx context.Context, payload any) (any, error) {
payloadInt := rand.IntN(up.Max-up.Min+1) + up.Min
return payloadInt, nil
}
func (up *IntRandom) Type() string {
return up.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "int.random",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
min, ok := params["min"]
if !ok {
return nil, errors.New("int.random requires a min parameter")
}
minFloat, ok := min.(float64)
if !ok {
return nil, errors.New("int.random min must be a number")
}
max, ok := params["max"]
if !ok {
return nil, errors.New("int.random requires a max parameter")
}
maxFloat, ok := max.(float64)
if !ok {
return nil, errors.New("int.random max must be a number")
}
if maxFloat < minFloat {
return nil, errors.New("int.random max must be greater than min")
}
return &IntRandom{config: config, Min: int(minFloat), Max: int(maxFloat)}, nil
},
})
}

View File

@@ -0,0 +1,42 @@
package processor
import (
"bytes"
"context"
"encoding/json"
"github.com/jwetzell/showbridge-go/internal/config"
)
type JsonEncode struct {
config config.ProcessorConfig
}
func (je *JsonEncode) Process(ctx context.Context, payload any) (any, error) {
var payloadBuffer bytes.Buffer
err := json.NewEncoder(&payloadBuffer).Encode(payload)
if err != nil {
return nil, err
}
payloadBytes := payloadBuffer.Bytes()
payloadBytes = payloadBytes[0 : len(payloadBytes)-1]
return payloadBytes, nil
}
func (je *JsonEncode) Type() string {
return je.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "json.encode",
New: func(config config.ProcessorConfig) (Processor, error) {
return &JsonEncode{config: config}, nil
},
})
}

View File

@@ -0,0 +1,45 @@
package processor_test
import (
"slices"
"testing"
"github.com/jwetzell/osc-go"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestGoodJsonEncode(t *testing.T) {
stringEncoder := processor.JsonEncode{}
tests := []struct {
processor processor.Processor
name string
payload any
expected []byte
}{
{
processor: &stringEncoder,
name: "hello",
payload: osc.OSCMessage{
Address: "/hello",
},
expected: []byte("{\"address\":\"/hello\",\"args\":null}"),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload)
gotBytes, ok := got.([]byte)
if !ok {
t.Fatalf("json.encode returned a %T payload: %s", got, got)
}
if err != nil {
t.Fatalf("json.encode failed: %s", err)
}
if !slices.Equal(gotBytes, test.expected) {
t.Fatalf("json.encode got %x, expected %s", got, test.expected)
}
})
}
}

View File

@@ -0,0 +1,376 @@
//go:build cgo
package processor
import (
"bytes"
"context"
"errors"
"fmt"
"strconv"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
"gitlab.com/gomidi/midi/v2"
)
// TODO(jwetzell): support using numbers in config file treated as hardcoded values
type MIDIMessageCreate struct {
config config.ProcessorConfig
ProcessFunc func(ctx context.Context, payload any) (any, error)
}
func (mmc *MIDIMessageCreate) Process(ctx context.Context, payload any) (any, error) {
return mmc.ProcessFunc(ctx, payload)
}
func (mmc *MIDIMessageCreate) Type() string {
return mmc.config.Type
}
func newMidiNoteOnCreate(config config.ProcessorConfig) (Processor, error) {
params := config.Params
channel, ok := params["channel"]
if !ok {
return nil, errors.New("midi.message.create NoteOn requires a channel parameter")
}
channelString, ok := channel.(string)
if !ok {
return nil, errors.New("midi.message.create NoteOn channel must be a string")
}
channelTemplate, err := template.New("channel").Parse(channelString)
if err != nil {
return nil, err
}
note, ok := params["note"]
if !ok {
return nil, errors.New("midi.message.create NoteOn requires a note parameter")
}
noteString, ok := note.(string)
if !ok {
return nil, errors.New("midi.message.create NoteOn note must be a string")
}
noteTemplate, err := template.New("note").Parse(noteString)
if err != nil {
return nil, err
}
velocity, ok := params["velocity"]
if !ok {
return nil, errors.New("midi.message.create NoteOn requires a velocity parameter")
}
velocityString, ok := velocity.(string)
if !ok {
return nil, errors.New("midi.message.create NoteOn velocity must be a string")
}
velocityTemplate, err := template.New("velocity").Parse(velocityString)
if err != nil {
return nil, err
}
return &MIDIMessageCreate{config: config, ProcessFunc: func(ctx context.Context, payload any) (any, error) {
var channelBuffer bytes.Buffer
err := channelTemplate.Execute(&channelBuffer, payload)
if err != nil {
return nil, err
}
channelValue, err := strconv.ParseUint(channelBuffer.String(), 10, 8)
var noteBuffer bytes.Buffer
err = noteTemplate.Execute(&noteBuffer, payload)
if err != nil {
return nil, err
}
noteValue, err := strconv.ParseUint(noteBuffer.String(), 10, 8)
var velocityBuffer bytes.Buffer
err = velocityTemplate.Execute(&velocityBuffer, payload)
if err != nil {
return nil, err
}
velocityValue, err := strconv.ParseUint(velocityBuffer.String(), 10, 8)
payloadMessage := midi.NoteOn(uint8(channelValue), uint8(noteValue), uint8(velocityValue))
return payloadMessage, nil
}}, nil
}
func newMidiNoteOffCreate(config config.ProcessorConfig) (Processor, error) {
params := config.Params
channel, ok := params["channel"]
if !ok {
return nil, errors.New("midi.message.create NoteOn requires a channel parameter")
}
channelString, ok := channel.(string)
if !ok {
return nil, errors.New("midi.message.create NoteOn channel must be a string")
}
channelTemplate, err := template.New("channel").Parse(channelString)
if err != nil {
return nil, err
}
note, ok := params["note"]
if !ok {
return nil, errors.New("midi.message.create NoteOn requires a note parameter")
}
noteString, ok := note.(string)
if !ok {
return nil, errors.New("midi.message.create NoteOn note must be a string")
}
noteTemplate, err := template.New("note").Parse(noteString)
if err != nil {
return nil, err
}
return &MIDIMessageCreate{config: config, ProcessFunc: func(ctx context.Context, payload any) (any, error) {
var channelBuffer bytes.Buffer
err := channelTemplate.Execute(&channelBuffer, payload)
if err != nil {
return nil, err
}
channelValue, err := strconv.ParseUint(channelBuffer.String(), 10, 8)
var noteBuffer bytes.Buffer
err = noteTemplate.Execute(&noteBuffer, payload)
if err != nil {
return nil, err
}
noteValue, err := strconv.ParseUint(noteBuffer.String(), 10, 8)
payloadMessage := midi.NoteOff(uint8(channelValue), uint8(noteValue))
return payloadMessage, nil
}}, nil
}
func newMidiControlChangeCreate(config config.ProcessorConfig) (Processor, error) {
params := config.Params
channel, ok := params["channel"]
if !ok {
return nil, errors.New("midi.message.create ControlChange requires a channel parameter")
}
channelString, ok := channel.(string)
if !ok {
return nil, errors.New("midi.message.create ControlChange channel must be a string")
}
channelTemplate, err := template.New("channel").Parse(channelString)
if err != nil {
return nil, err
}
controller, ok := params["controller"]
if !ok {
return nil, errors.New("midi.message.create ControlChange requires a controller parameter")
}
controllerString, ok := controller.(string)
if !ok {
return nil, errors.New("midi.message.create ControlChange controller must be a string")
}
controllerTemplate, err := template.New("controller").Parse(controllerString)
if err != nil {
return nil, err
}
value, ok := params["value"]
if !ok {
return nil, errors.New("midi.message.create ControlChange requires a value parameter")
}
valueString, ok := value.(string)
if !ok {
return nil, errors.New("midi.message.create ControlChange value must be a string")
}
valueTemplate, err := template.New("value").Parse(valueString)
if err != nil {
return nil, err
}
return &MIDIMessageCreate{config: config, ProcessFunc: func(ctx context.Context, payload any) (any, error) {
var channelBuffer bytes.Buffer
err := channelTemplate.Execute(&channelBuffer, payload)
if err != nil {
return nil, err
}
channelValue, err := strconv.ParseUint(channelBuffer.String(), 10, 8)
var controllerBuffer bytes.Buffer
err = controllerTemplate.Execute(&controllerBuffer, payload)
if err != nil {
return nil, err
}
controllerValue, err := strconv.ParseUint(controllerBuffer.String(), 10, 8)
var valueBuffer bytes.Buffer
err = valueTemplate.Execute(&valueBuffer, payload)
if err != nil {
return nil, err
}
valueValue, err := strconv.ParseUint(valueBuffer.String(), 10, 8)
payloadMessage := midi.ControlChange(uint8(channelValue), uint8(controllerValue), uint8(valueValue))
return payloadMessage, nil
}}, nil
}
func newMidiProgramChangeCreate(config config.ProcessorConfig) (Processor, error) {
params := config.Params
channel, ok := params["channel"]
if !ok {
return nil, errors.New("midi.message.create ProgramChange requires a channel parameter")
}
channelString, ok := channel.(string)
if !ok {
return nil, errors.New("midi.message.create ProgramChange channel must be a string")
}
channelTemplate, err := template.New("channel").Parse(channelString)
if err != nil {
return nil, err
}
program, ok := params["program"]
if !ok {
return nil, errors.New("midi.message.create ProgramChange requires a program parameter")
}
programString, ok := program.(string)
if !ok {
return nil, errors.New("midi.message.create ProgramChange program must be a string")
}
programTemplate, err := template.New("program").Parse(programString)
if err != nil {
return nil, err
}
return &MIDIMessageCreate{config: config, ProcessFunc: func(ctx context.Context, payload any) (any, error) {
var channelBuffer bytes.Buffer
err := channelTemplate.Execute(&channelBuffer, payload)
if err != nil {
return nil, err
}
channelValue, err := strconv.ParseUint(channelBuffer.String(), 10, 8)
var programBuffer bytes.Buffer
err = programTemplate.Execute(&programBuffer, payload)
if err != nil {
return nil, err
}
programValue, err := strconv.ParseUint(programBuffer.String(), 10, 8)
payloadMessage := midi.ProgramChange(uint8(channelValue), uint8(programValue))
return payloadMessage, nil
}}, nil
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "midi.message.create",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
msgType, ok := params["type"]
if !ok {
return nil, errors.New("midi.message.create requires a type parameter")
}
msgTypeString, ok := msgType.(string)
if !ok {
return nil, errors.New("midi.message.create type parameter must be a string")
}
switch msgTypeString {
case "NoteOn", "noteon", "note_on":
return newMidiNoteOnCreate(config)
case "NoteOff", "noteoff", "note_off":
return newMidiNoteOffCreate(config)
case "ControlChange", "controlchange", "control_change":
return newMidiControlChangeCreate(config)
case "ProgramChange", "programchange", "program_change":
return newMidiProgramChangeCreate(config)
default:
return nil, fmt.Errorf("midi.message.create does not support type %s", msgTypeString)
}
},
})
}

View File

@@ -1,23 +1,24 @@
//go:build cgo
package processing
package processor
import (
"context"
"fmt"
"errors"
"github.com/jwetzell/showbridge-go/internal/config"
"gitlab.com/gomidi/midi/v2"
)
type MIDIMessageDecode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (mmd *MIDIMessageDecode) Process(ctx context.Context, payload any) (any, error) {
payloadBytes, ok := payload.([]byte)
if !ok {
return nil, fmt.Errorf("midi.message.decode processor only accepts a []byte")
return nil, errors.New("midi.message.decode processor only accepts a []byte")
}
payloadMessage := midi.Message(payloadBytes)
@@ -32,7 +33,7 @@ func (mmd *MIDIMessageDecode) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "midi.message.decode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &MIDIMessageDecode{config: config}, nil
},
})

View File

@@ -1,23 +1,24 @@
//go:build cgo
package processing
package processor
import (
"context"
"fmt"
"errors"
"github.com/jwetzell/showbridge-go/internal/config"
"gitlab.com/gomidi/midi/v2"
)
type MIDIMessageEncode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (mme *MIDIMessageEncode) Process(ctx context.Context, payload any) (any, error) {
payloadMessage, ok := payload.(midi.Message)
if !ok {
return nil, fmt.Errorf("midi.message.encode processor only accepts an midi.Message")
return nil, errors.New("midi.message.encode processor only accepts an midi.Message")
}
return payloadMessage.Bytes(), nil
@@ -30,7 +31,7 @@ func (mme *MIDIMessageEncode) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "midi.message.encode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &MIDIMessageEncode{config: config}, nil
},
})

View File

@@ -0,0 +1,55 @@
//go:build cgo
package processor
import (
"context"
"errors"
"github.com/jwetzell/showbridge-go/internal/config"
"gitlab.com/gomidi/midi/v2"
)
type MIDIMessageFilter struct {
config config.ProcessorConfig
MIDIType string
}
func (mmf *MIDIMessageFilter) Process(ctx context.Context, payload any) (any, error) {
payloadMessage, ok := payload.(midi.Message)
if !ok {
return nil, errors.New("midi.message.filter processor only accepts an midi.Message")
}
if payloadMessage.Type().String() != mmf.MIDIType {
return nil, nil
}
return payloadMessage, nil
}
func (mmf *MIDIMessageFilter) Type() string {
return mmf.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "midi.message.filter",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
midiType, ok := params["type"]
if !ok {
return nil, errors.New("midi.message.filter requires a type parameter")
}
midiTypeString, ok := midiType.(string)
if !ok {
return nil, errors.New("midi.message.filter type must be a string")
}
return &MIDIMessageFilter{config: config, MIDIType: midiTypeString}, nil
},
})
}

View File

@@ -0,0 +1,91 @@
//go:build cgo
package processor
import (
"context"
"errors"
"fmt"
"github.com/jwetzell/showbridge-go/internal/config"
"gitlab.com/gomidi/midi/v2"
)
type MIDIMessageUnpack struct {
config config.ProcessorConfig
}
type MIDINoteOn struct {
Channel uint8
Note uint8
Velocity uint8
}
type MIDINoteOff struct {
Channel uint8
Note uint8
Velocity uint8
}
type MIDIControlChange struct {
Channel uint8
Control uint8
Value uint8
}
type MIDIProgramChange struct {
Channel uint8
Program uint8
}
type MIDIPitchBend struct {
Channel uint8
Relative int16
Absolute uint16
}
func (mmu *MIDIMessageUnpack) Process(ctx context.Context, payload any) (any, error) {
payloadMidi, ok := payload.(midi.Message)
if !ok {
return nil, errors.New("midi.message.unpack processor only accepts a midi.Message")
}
switch payloadMidi.Type() {
case midi.NoteOnMsg:
noteOnMsg := MIDINoteOn{}
payloadMidi.GetNoteOn(&noteOnMsg.Channel, &noteOnMsg.Note, &noteOnMsg.Velocity)
return noteOnMsg, nil
case midi.NoteOffMsg:
noteOffMsg := MIDINoteOff{}
payloadMidi.GetNoteOff(&noteOffMsg.Channel, &noteOffMsg.Note, &noteOffMsg.Velocity)
return noteOffMsg, nil
case midi.ControlChangeMsg:
controlChangeMsg := MIDIControlChange{}
payloadMidi.GetControlChange(&controlChangeMsg.Channel, &controlChangeMsg.Control, &controlChangeMsg.Value)
return controlChangeMsg, nil
case midi.ProgramChangeMsg:
programChangeMsg := MIDIProgramChange{}
payloadMidi.GetProgramChange(&programChangeMsg.Channel, &programChangeMsg.Program)
return programChangeMsg, nil
case midi.PitchBendMsg:
pitchBendMsg := MIDIPitchBend{}
payloadMidi.GetPitchBend(&pitchBendMsg.Channel, &pitchBendMsg.Relative, &pitchBendMsg.Absolute)
return pitchBendMsg, nil
default:
return nil, fmt.Errorf("midi.message.unpack message type not supported %v", payloadMidi.Type())
}
}
func (mmu *MIDIMessageUnpack) Type() string {
return mmu.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "midi.message.unpack",
New: func(config config.ProcessorConfig) (Processor, error) {
return &MIDIMessageUnpack{config: config}, nil
},
})
}

View File

@@ -0,0 +1,133 @@
package processor
import (
"context"
"errors"
"github.com/jwetzell/showbridge-go/internal/config"
)
type MQTTMessage struct {
topic string
qos byte
payload []byte
retained bool
}
type MQTTMessageCreate struct {
config config.ProcessorConfig
Topic string
QoS byte
Retained bool
Payload []byte
}
func (mm MQTTMessage) Duplicate() bool {
// TODO(jwetzell): implement?
return false
}
func (mm MQTTMessage) Qos() byte {
return mm.qos
}
func (mm MQTTMessage) Retained() bool {
return mm.retained
}
func (mm MQTTMessage) Topic() string {
return mm.topic
}
func (mm MQTTMessage) MessageID() uint16 {
// TODO(jwetzell): implement?
return 0
}
func (mm MQTTMessage) Payload() []byte {
return mm.payload
}
func (mm MQTTMessage) Ack() {}
func (mmc *MQTTMessageCreate) Process(ctx context.Context, payload any) (any, error) {
// TODO(jwetzell): support templating
message := MQTTMessage{
topic: mmc.Topic,
qos: mmc.QoS,
retained: mmc.Retained,
payload: mmc.Payload,
}
return message, nil
}
func (mmc *MQTTMessageCreate) Type() string {
return mmc.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "mqtt.message.create",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
topic, ok := params["topic"]
if !ok {
return nil, errors.New("mqtt.message.create requires a topic parameter")
}
topicString, ok := topic.(string)
if !ok {
return nil, errors.New("mqtt.message.create topic must be a string")
}
qos, ok := params["qos"]
if !ok {
return nil, errors.New("mqtt.message.create requires a qos parameter")
}
qosByte, ok := qos.(float64)
if !ok {
return nil, errors.New("mqtt.message.create qos must be a number")
}
retained, ok := params["retained"]
if !ok {
return nil, errors.New("mqtt.message.create requires a retained parameter")
}
retainedBool, ok := retained.(bool)
if !ok {
return nil, errors.New("mqtt.message.create retained must be a boolean")
}
//TODO(jwetzell): convert payload into []byte or string for sending
payload, ok := params["payload"]
if !ok {
return nil, errors.New("mqtt.message.create requires a payload parameter")
}
if payloadBytes, ok := payload.([]byte); ok {
return &MQTTMessageCreate{config: config, Topic: topicString, QoS: byte(qosByte), Retained: retainedBool, Payload: payloadBytes}, nil
}
payloadString, ok := payload.(string)
if !ok {
return nil, errors.New("mqtt.message.create payload must be a string or byte array")
}
payloadBytes := []byte(payloadString)
return &MQTTMessageCreate{config: config, Topic: topicString, QoS: byte(qosByte), Retained: retainedBool, Payload: payloadBytes}, nil
},
})
}

View File

@@ -1,21 +1,22 @@
package processing
package processor
import (
"context"
"fmt"
"errors"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/jwetzell/showbridge-go/internal/config"
)
type MQTTMessageEncode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (mme *MQTTMessageEncode) Process(ctx context.Context, payload any) (any, error) {
payloadMessage, ok := payload.(mqtt.Message)
if !ok {
return nil, fmt.Errorf("mqtt.message.encode processor only accepts an mqtt.Message")
return nil, errors.New("mqtt.message.encode processor only accepts an mqtt.Message")
}
return payloadMessage.Payload(), nil
@@ -28,7 +29,7 @@ func (mme *MQTTMessageEncode) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "mqtt.message.encode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &MQTTMessageEncode{config: config}, nil
},
})

View File

@@ -1,10 +1,12 @@
package processing
package processor
import (
"bytes"
"context"
"fmt"
"errors"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
)
type NATSMessage struct {
@@ -13,8 +15,8 @@ type NATSMessage struct {
}
type NATSMessageCreate struct {
config ProcessorConfig
Subject string
config config.ProcessorConfig
Subject *template.Template
Payload *template.Template
}
@@ -29,8 +31,17 @@ func (nmc *NATSMessageCreate) Process(ctx context.Context, payload any) (any, er
payloadString := payloadBuffer.String()
var subjectBuffer bytes.Buffer
err = nmc.Subject.Execute(&subjectBuffer, payload)
if err != nil {
return nil, err
}
subjectString := subjectBuffer.String()
message := NATSMessage{
Subject: nmc.Subject,
Subject: subjectString,
Payload: []byte(payloadString),
}
@@ -44,31 +55,36 @@ func (nmc *NATSMessageCreate) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "nats.message.create",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
// TODO(jwetzell): support template for subject
subject, ok := params["subject"]
if !ok {
return nil, fmt.Errorf("nats.message.create requires a subject parameter")
return nil, errors.New("nats.message.create requires a subject parameter")
}
subjectString, ok := subject.(string)
if !ok {
return nil, fmt.Errorf("nats.message.create subject must be a string")
return nil, errors.New("nats.message.create subject must be a string")
}
subjectTemplate, err := template.New("subject").Parse(subjectString)
if err != nil {
return nil, err
}
payload, ok := params["payload"]
if !ok {
return nil, fmt.Errorf("osc.message.create requires a payload parameter")
return nil, errors.New("nats.message.create requires a payload parameter")
}
payloadString, ok := payload.(string)
if !ok {
return nil, fmt.Errorf("osc.message.create payload must be a string")
return nil, errors.New("nats.message.create payload must be a string")
}
payloadTemplate, err := template.New("payload").Parse(payloadString)
@@ -77,7 +93,7 @@ func init() {
return nil, err
}
return &NATSMessageCreate{config: config, Subject: subjectString, Payload: payloadTemplate}, nil
return &NATSMessageCreate{config: config, Subject: subjectTemplate, Payload: payloadTemplate}, nil
},
})
}

View File

@@ -1,21 +1,22 @@
package processing
package processor
import (
"context"
"fmt"
"errors"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/nats-io/nats.go"
)
type NATSMessageEncode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (nme *NATSMessageEncode) Process(ctx context.Context, payload any) (any, error) {
payloadMessage, ok := payload.(*nats.Msg)
if !ok {
return nil, fmt.Errorf("nats.message.encode processor only accepts an nats.Msg")
return nil, errors.New("nats.message.encode processor only accepts an nats.Msg")
}
return payloadMessage.Data, nil
@@ -28,7 +29,7 @@ func (nme *NATSMessageEncode) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "nats.message.encode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &NATSMessageEncode{config: config}, nil
},
})

View File

@@ -1,27 +1,29 @@
package processing
package processor
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"strconv"
"text/template"
"github.com/jwetzell/osc-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type OSCMessageCreate struct {
config ProcessorConfig
config config.ProcessorConfig
Address *template.Template
Args []*template.Template
Types string
}
func (o *OSCMessageCreate) Process(ctx context.Context, payload any) (any, error) {
func (omc *OSCMessageCreate) Process(ctx context.Context, payload any) (any, error) {
var addressBuffer bytes.Buffer
err := o.Address.Execute(&addressBuffer, payload)
err := omc.Address.Execute(&addressBuffer, payload)
if err != nil {
return nil, err
@@ -30,11 +32,11 @@ func (o *OSCMessageCreate) Process(ctx context.Context, payload any) (any, error
addressString := addressBuffer.String()
if len(addressString) == 0 {
return nil, fmt.Errorf("osc.message.create address must not be empty")
return nil, errors.New("osc.message.create address must not be empty")
}
if addressString[0] != '/' {
return nil, fmt.Errorf("osc.message.create address must start with '/'")
return nil, errors.New("osc.message.create address must start with '/'")
}
payloadMessage := osc.OSCMessage{
@@ -43,7 +45,7 @@ func (o *OSCMessageCreate) Process(ctx context.Context, payload any) (any, error
args := []osc.OSCArg{}
for argIndex, argTemplate := range o.Args {
for argIndex, argTemplate := range omc.Args {
var argBuffer bytes.Buffer
err := argTemplate.Execute(&argBuffer, payload)
@@ -53,7 +55,7 @@ func (o *OSCMessageCreate) Process(ctx context.Context, payload any) (any, error
argString := argBuffer.String()
typedArg, err := argToTypedArg(argString, o.Types[argIndex])
typedArg, err := argToTypedArg(argString, omc.Types[argIndex])
if err != nil {
return nil, err
@@ -69,25 +71,25 @@ func (o *OSCMessageCreate) Process(ctx context.Context, payload any) (any, error
return payloadMessage, nil
}
func (o *OSCMessageCreate) Type() string {
return o.config.Type
func (omc *OSCMessageCreate) Type() string {
return omc.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "osc.message.create",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
address, ok := params["address"]
if !ok {
return nil, fmt.Errorf("osc.message.create requires an address parameter")
return nil, errors.New("osc.message.create requires an address parameter")
}
addressString, ok := address.(string)
if !ok {
return nil, fmt.Errorf("osc.message.create address must be a string")
return nil, errors.New("osc.message.create address must be a string")
}
addressTemplate, err := template.New("address").Parse(addressString)
@@ -108,17 +110,17 @@ func init() {
types, ok := params["types"]
if !ok {
return nil, fmt.Errorf("osc.message.create requires a types parameter with args")
return nil, errors.New("osc.message.create requires a types parameter with args")
}
typesString, ok := types.(string)
if !ok {
return nil, fmt.Errorf("osc.message.create types must be a string")
return nil, errors.New("osc.message.create types must be a string")
}
if len(rawArgs) != len(typesString) {
return nil, fmt.Errorf("osc.message.create args and types must be the same length")
return nil, errors.New("osc.message.create args and types must be the same length")
}
argTemplates := []*template.Template{}
@@ -127,7 +129,7 @@ func init() {
argString, ok := rawArg.(string)
if !ok {
return nil, fmt.Errorf("osc.message.create arg must be a string")
return nil, errors.New("osc.message.create arg must be a string")
}
argTemplate, err := template.New("arg").Parse(argString)

View File

@@ -1,29 +1,30 @@
package processing
package processor
import (
"context"
"fmt"
"errors"
osc "github.com/jwetzell/osc-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type OSCMessageDecode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (o *OSCMessageDecode) Process(ctx context.Context, payload any) (any, error) {
func (omd *OSCMessageDecode) Process(ctx context.Context, payload any) (any, error) {
payloadBytes, ok := payload.([]byte)
if !ok {
return nil, fmt.Errorf("osc.message.decode processor only accepts a []byte payload")
return nil, errors.New("osc.message.decode processor only accepts a []byte payload")
}
if len(payloadBytes) == 0 {
return nil, fmt.Errorf("osc.message.decode processor can't work on empty []byte")
return nil, errors.New("osc.message.decode processor can't work on empty []byte")
}
if payloadBytes[0] != '/' {
return nil, fmt.Errorf("osc.message.decode processor needs an OSC looking []byte")
return nil, errors.New("osc.message.decode processor needs an OSC looking []byte")
}
message, err := osc.MessageFromBytes(payloadBytes)
@@ -33,14 +34,14 @@ func (o *OSCMessageDecode) Process(ctx context.Context, payload any) (any, error
return message, nil
}
func (o *OSCMessageDecode) Type() string {
return o.config.Type
func (omd *OSCMessageDecode) Type() string {
return omd.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "osc.message.decode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &OSCMessageDecode{config: config}, nil
},
})

View File

@@ -0,0 +1,37 @@
package processor
import (
"context"
"errors"
osc "github.com/jwetzell/osc-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type OSCMessageEncode struct {
config config.ProcessorConfig
}
func (ome *OSCMessageEncode) Process(ctx context.Context, payload any) (any, error) {
payloadMessage, ok := payload.(osc.OSCMessage)
if !ok {
return nil, errors.New("osc.message.encode processor only accepts an OSCMessage")
}
bytes := payloadMessage.ToBytes()
return bytes, nil
}
func (ome *OSCMessageEncode) Type() string {
return ome.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "osc.message.encode",
New: func(config config.ProcessorConfig) (Processor, error) {
return &OSCMessageEncode{config: config}, nil
},
})
}

View File

@@ -1,53 +1,55 @@
package processing
package processor
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"github.com/jwetzell/osc-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type OSCMessageFilter struct {
config ProcessorConfig
config config.ProcessorConfig
Address *regexp.Regexp
}
func (o *OSCMessageFilter) Process(ctx context.Context, payload any) (any, error) {
func (omf *OSCMessageFilter) Process(ctx context.Context, payload any) (any, error) {
payloadMessage, ok := payload.(osc.OSCMessage)
if !ok {
return nil, fmt.Errorf("osc.message.filter can only operate on OSCMessage payloads")
return nil, errors.New("osc.message.filter can only operate on OSCMessage payloads")
}
if !o.Address.MatchString(payloadMessage.Address) {
if !omf.Address.MatchString(payloadMessage.Address) {
return nil, nil
}
return payloadMessage, nil
}
func (o *OSCMessageFilter) Type() string {
return o.config.Type
func (omf *OSCMessageFilter) Type() string {
return omf.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "osc.message.filter",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
address, ok := params["address"]
if !ok {
return nil, fmt.Errorf("osc.message.filter requires an address parameter")
return nil, errors.New("osc.message.filter requires an address parameter")
}
addressString, ok := address.(string)
if !ok {
return nil, fmt.Errorf("osc.message.filter address must be a string")
return nil, errors.New("osc.message.filter address must be a string")
}
addressPattern := strings.ReplaceAll(addressString, "?", ".")

View File

@@ -1,29 +1,29 @@
package processing
package processor
import (
"bytes"
"context"
"fmt"
"errors"
"text/template"
osc "github.com/jwetzell/osc-go"
"github.com/jwetzell/showbridge-go/internal/config"
)
type OSCMessageTransform struct {
config ProcessorConfig
config config.ProcessorConfig
Address *template.Template
}
func (o *OSCMessageTransform) Process(ctx context.Context, payload any) (any, error) {
func (omt *OSCMessageTransform) Process(ctx context.Context, payload any) (any, error) {
payloadMessage, ok := payload.(osc.OSCMessage)
if !ok {
return nil, fmt.Errorf("osc.message.transform processor only accepts an OSCMessage")
return nil, errors.New("osc.message.transform processor only accepts an OSCMessage")
}
var addressBuffer bytes.Buffer
//TODO(jwetzell): actually inject data into template
err := o.Address.Execute(&addressBuffer, payloadMessage)
err := omt.Address.Execute(&addressBuffer, payloadMessage)
if err != nil {
return nil, err
@@ -32,11 +32,11 @@ func (o *OSCMessageTransform) Process(ctx context.Context, payload any) (any, er
addressString := addressBuffer.String()
if len(addressString) == 0 {
return nil, fmt.Errorf("osc.message.transform address must not be empty")
return nil, errors.New("osc.message.transform address must not be empty")
}
if addressString[0] != '/' {
return nil, fmt.Errorf("osc.message.transform address must start with '/'")
return nil, errors.New("osc.message.transform address must start with '/'")
}
payloadMessage.Address = addressString
@@ -44,25 +44,25 @@ func (o *OSCMessageTransform) Process(ctx context.Context, payload any) (any, er
return payloadMessage, nil
}
func (o *OSCMessageTransform) Type() string {
return o.config.Type
func (omt *OSCMessageTransform) Type() string {
return omt.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "osc.message.transform",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
address, ok := params["address"]
if !ok {
return nil, fmt.Errorf("osc.message.transform requires an address parameter")
return nil, errors.New("osc.message.transform requires an address parameter")
}
addressString, ok := address.(string)
if !ok {
return nil, fmt.Errorf("osc.message.transform address must be a string")
return nil, errors.New("osc.message.transform address must be a string")
}
addressTemplate, err := template.New("address").Parse(addressString)

View File

@@ -1,9 +1,11 @@
package processing
package processor
import (
"context"
"fmt"
"sync"
"github.com/jwetzell/showbridge-go/internal/config"
)
type Processor interface {
@@ -11,14 +13,9 @@ type Processor interface {
Process(context.Context, any) (any, error)
}
type ProcessorConfig struct {
Type string `json:"type"`
Params map[string]any `json:"params"`
}
type ProcessorRegistration struct {
Type string `json:"type"`
New func(ProcessorConfig) (Processor, error)
New func(config.ProcessorConfig) (Processor, error)
}
func RegisterProcessor(processor ProcessorRegistration) {

View File

@@ -0,0 +1,62 @@
package processor_test
import (
"context"
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
type TestProcessor struct {
}
func (p *TestProcessor) Type() string {
return "test"
}
func (p *TestProcessor) Process(ctx context.Context, input any) (any, error) {
return input, nil
}
func TestProcessorBadRegistrationNoType(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("processor registration should have panicked but did not")
}
}()
processor.RegisterProcessor(processor.ProcessorRegistration{
Type: "",
New: func(config config.ProcessorConfig) (processor.Processor, error) {
return &TestProcessor{}, nil
},
})
}
func TestProcessorBadRegistrationNoNew(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("processor registration should have panicked but did not")
}
}()
processor.RegisterProcessor(processor.ProcessorRegistration{
Type: "test",
New: nil,
})
}
func TestProcessorBadRegistrationExistingType(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("processor registration should have panicked but did not")
}
}()
processor.RegisterProcessor(processor.ProcessorRegistration{
Type: "string.create",
New: func(config config.ProcessorConfig) (processor.Processor, error) {
return &TestProcessor{}, nil
},
})
}

View File

@@ -1,16 +1,17 @@
package processing
package processor
import (
"context"
"fmt"
"errors"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
"github.com/jwetzell/showbridge-go/internal/config"
)
// NOTE(jwetzell): see language definition https://expr-lang.org/docs/language-definition
type ScriptExpr struct {
config ProcessorConfig
config config.ProcessorConfig
Program *vm.Program
}
@@ -31,19 +32,19 @@ func (se *ScriptExpr) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "script.expr",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
expression, ok := params["expression"]
if !ok {
return nil, fmt.Errorf("script.expr requires an expression parameter")
return nil, errors.New("script.expr requires an expression parameter")
}
expressionString, ok := expression.(string)
if !ok {
return nil, fmt.Errorf("script.expr expression must be a string")
return nil, errors.New("script.expr expression must be a string")
}
program, err := expr.Compile(expressionString)

View File

@@ -0,0 +1,171 @@
package processor_test
import (
"testing"
"github.com/expr-lang/expr"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestScriptExprFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["script.expr"]
if !ok {
t.Fatalf("script.expr processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "script.expr",
Params: map[string]any{
"expression": "foo + bar",
},
})
if err != nil {
t.Fatalf("failed to create script.expr processor: %s", err)
}
if processorInstance.Type() != "script.expr" {
t.Fatalf("script.expr processor has wrong type: %s", processorInstance.Type())
}
}
func TestScriptExprNoProgram(t *testing.T) {
registration, ok := processor.ProcessorRegistry["script.expr"]
if !ok {
t.Fatalf("script.expr processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "script.expr",
Params: map[string]any{},
})
if err == nil {
t.Fatalf("script.expr processor should have thrown an error when creating")
}
}
func TestScriptExprBadConfigWrongExpressionType(t *testing.T) {
registration, ok := processor.ProcessorRegistry["script.expr"]
if !ok {
t.Fatalf("script.expr processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "script.expr",
Params: map[string]any{
"expression": 12345,
},
})
if err == nil {
t.Fatalf("script.expr processor should have thrown an error when creating with non-string expression")
}
}
func TestScriptExprBadConfigNonCompilingExpression(t *testing.T) {
registration, ok := processor.ProcessorRegistry["script.expr"]
if !ok {
t.Fatalf("script.expr processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "script.expr",
Params: map[string]any{
"expression": "foo + ",
},
})
if err == nil {
t.Fatalf("script.expr processor should have thrown an error when creating with non-compiling expression")
}
}
func TestGoodScriptExpr(t *testing.T) {
tests := []struct {
program string
name string
payload map[string]any
expected any
}{
{
program: "foo + bar",
name: "number",
payload: map[string]any{
"foo": 1,
"bar": 1,
},
expected: 2,
},
{
program: "foo + bar",
name: "string",
payload: map[string]any{
"foo": "1",
"bar": "1",
},
expected: "11",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
program, err := expr.Compile(test.program)
if err != nil {
t.Fatalf("script.expr failed to compile program: %s", err)
}
exprProcessor := &processor.ScriptExpr{Program: program}
got, err := exprProcessor.Process(t.Context(), test.payload)
if err != nil {
t.Fatalf("script.expr failed: %s", err)
}
//TODO(jwetzell): work out better way to compare the any/any
if got != test.expected {
t.Fatalf("script.expr got %+v (%T), expected %+v (%T)", got, got, test.expected, test.expected)
}
})
}
}
func TestBadScriptExpr(t *testing.T) {
tests := []struct {
program string
name string
payload map[string]any
errorString string
}{
{
name: "accessing missing field",
program: "foo + bar",
payload: map[string]any{
"foo": 1,
},
errorString: "invalid operation: int + <nil> (1:5)\n | foo + bar\n | ....^",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
program, err := expr.Compile(test.program)
if err != nil {
t.Fatalf("script.expr failed to compile program: %s", err)
}
exprProcessor := &processor.ScriptExpr{Program: program}
got, err := exprProcessor.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("script.expr expected to fail but succeeded, got: %v", got)
}
if err.Error() != test.errorString {
t.Fatalf("script.expr got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -1,15 +1,16 @@
package processing
package processor
import (
"context"
"encoding/json"
"fmt"
"errors"
"github.com/jwetzell/showbridge-go/internal/config"
"modernc.org/quickjs"
)
type ScriptJS struct {
config ProcessorConfig
config config.ProcessorConfig
Program string
}
@@ -67,19 +68,19 @@ func (sj *ScriptJS) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "script.js",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
program, ok := params["program"]
if !ok {
return nil, fmt.Errorf("script.js requires a program parameter")
return nil, errors.New("script.js requires a program parameter")
}
programString, ok := program.(string)
if !ok {
return nil, fmt.Errorf("script.js program must be a string")
return nil, errors.New("script.js program must be a string")
}
return &ScriptJS{config: config, Program: programString}, nil

View File

@@ -0,0 +1,179 @@
package processor_test
import (
"maps"
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestScriptJSFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["script.js"]
if !ok {
t.Fatalf("script.js processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "script.js",
Params: map[string]any{
"program": `
payload = payload + 1
`,
},
})
if err != nil {
t.Fatalf("failed to create script.js processor: %s", err)
}
if processorInstance.Type() != "script.js" {
t.Fatalf("script.js processor has wrong type: %s", processorInstance.Type())
}
payload := 1
expected := 2
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("script.js processing failed: %s", err)
}
if got != expected {
t.Fatalf("script.js got %+v, expected %+v", got, expected)
}
}
func TestScriptJSNoProgram(t *testing.T) {
registration, ok := processor.ProcessorRegistry["script.js"]
if !ok {
t.Fatalf("script.js processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "script.js",
Params: map[string]any{},
})
if err == nil {
t.Fatalf("script.js processor should have thrown an error when creating")
}
}
func TestScriptJSBadConfigWrongProgramType(t *testing.T) {
registration, ok := processor.ProcessorRegistry["script.js"]
if !ok {
t.Fatalf("script.js processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "script.js",
Params: map[string]any{
"program": 12345,
},
})
if err == nil {
t.Fatalf("script.js processor should have thrown an error when creating with non-string program")
}
}
func TestGoodScriptJS(t *testing.T) {
tests := []struct {
processor processor.Processor
name string
payload any
expected any
}{
{
name: "number",
processor: &processor.ScriptJS{Program: `
payload = payload + 1
`},
payload: 1,
expected: 2,
},
{
name: "string",
processor: &processor.ScriptJS{Program: `
payload = payload + "1"
`},
payload: "1",
expected: "11",
},
{
name: "object",
processor: &processor.ScriptJS{Program: `
payload = { key: payload }
`},
payload: "1",
expected: map[string]any{"key": "1"},
},
{
name: "nil",
processor: &processor.ScriptJS{Program: `
payload = undefined
`},
payload: "1",
expected: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload)
if err != nil {
t.Fatalf("script.js process failed: %s", err)
}
//TODO(jwetzell): work out better way to compare the any/any
gotMap, ok := got.(map[string]interface{})
if ok {
// got a map
expectedMap, ok := test.expected.(map[string]interface{})
if ok {
if !maps.Equal(gotMap, expectedMap) {
t.Fatalf("script.js got %+v, expected %+v", got, test.expected)
}
} else {
t.Fatalf("script.js got %+v, expected %+v", got, test.expected)
}
} else {
if got != test.expected {
t.Fatalf("script.js got %+v, expected %+v", got, test.expected)
}
}
})
}
}
func TestBadScriptJS(t *testing.T) {
tests := []struct {
name string
processor processor.Processor
payload any
errorString string
}{
{
name: "accessing not defined variable",
processor: &processor.ScriptJS{Program: `paylod = foo`},
payload: 0,
errorString: "ReferenceError: 'foo' is not defined",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("script.js expected to fail but succeeded, got: %v", got)
}
if err.Error() != test.errorString {
t.Fatalf("script.js got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -0,0 +1,92 @@
package processor
import (
"context"
"fmt"
extism "github.com/extism/go-sdk"
"github.com/jwetzell/showbridge-go/internal/config"
)
type ScriptWASM struct {
config config.ProcessorConfig
Program *extism.CompiledPlugin
Function string
}
func (se *ScriptWASM) Process(ctx context.Context, payload any) (any, error) {
payloadBytes, ok := payload.([]byte)
if !ok {
return nil, fmt.Errorf("script.wasm can only operator on byte array")
}
program, err := se.Program.Instance(ctx, extism.PluginInstanceConfig{})
if err != nil {
return nil, err
}
_, output, err := program.Call(se.Function, payloadBytes)
if err != nil {
return nil, err
}
return output, nil
}
func (se *ScriptWASM) Type() string {
return se.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "script.wasm",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
path, ok := params["path"]
if !ok {
return nil, fmt.Errorf("script.wasm requires a path parameter")
}
pathString, ok := path.(string)
if !ok {
return nil, fmt.Errorf("script.wasm path must be a string")
}
functionString := "process"
function, ok := params["function"]
if ok {
specificFunctionString, ok := function.(string)
if !ok {
return nil, fmt.Errorf("script.wasm function must be a string")
}
functionString = specificFunctionString
}
manifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{
Path: pathString,
},
},
}
program, err := extism.NewCompiledPlugin(context.Background(), manifest, extism.PluginConfig{}, []extism.HostFunction{})
if err != nil {
return nil, err
}
return &ScriptWASM{config: config, Program: program, Function: functionString}, nil
},
})
}

View File

@@ -0,0 +1,97 @@
package processor
import (
"bytes"
"context"
"errors"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
)
type SipResponseAudioCreate struct {
config config.ProcessorConfig
PreWait int
PostWait int
AudioFile *template.Template
}
type SipAudioFileResponse struct {
PreWait int
PostWait int
AudioFile string
}
func (scc *SipResponseAudioCreate) Process(ctx context.Context, payload any) (any, error) {
var audioFileBuffer bytes.Buffer
err := scc.AudioFile.Execute(&audioFileBuffer, payload)
if err != nil {
return nil, err
}
audioFileString := audioFileBuffer.String()
return SipAudioFileResponse{
PreWait: scc.PreWait,
PostWait: scc.PostWait,
AudioFile: audioFileString,
}, nil
}
func (scc *SipResponseAudioCreate) Type() string {
return scc.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "sip.response.audio.create",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
preWait, ok := params["preWait"]
if !ok {
return nil, errors.New("sip.response.audio.create requires a preWait parameter")
}
preWaitNum, ok := preWait.(float64)
if !ok {
return nil, errors.New("sip.response.audio.create preWait must be a number")
}
postWait, ok := params["postWait"]
if !ok {
return nil, errors.New("sip.response.audio.create requires a postWait parameter")
}
postWaitNum, ok := postWait.(float64)
if !ok {
return nil, errors.New("sip.response.audio.create postWait must be a number")
}
audioFile, ok := params["audioFile"]
if !ok {
return nil, errors.New("sip.response.audio.create requires a audioFile parameter")
}
audioFileString, ok := audioFile.(string)
if !ok {
return nil, errors.New("sip.response.audio.create audioFile must be a string")
}
audioFileTemplate, err := template.New("audioFile").Parse(audioFileString)
if err != nil {
return nil, err
}
return &SipResponseAudioCreate{config: config, AudioFile: audioFileTemplate, PreWait: int(preWaitNum), PostWait: int(postWaitNum)}, nil
},
})
}

View File

@@ -0,0 +1,103 @@
package processor
import (
"bytes"
"context"
"errors"
"regexp"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
)
type SipResponseDTMFCreate struct {
config config.ProcessorConfig
PreWait int
PostWait int
Digits *template.Template
validDTMF *regexp.Regexp
}
type SipDTMFResponse struct {
PreWait int
PostWait int
Digits string
}
func (scc *SipResponseDTMFCreate) Process(ctx context.Context, payload any) (any, error) {
var digitsBuffer bytes.Buffer
err := scc.Digits.Execute(&digitsBuffer, payload)
if err != nil {
return nil, err
}
digitsString := digitsBuffer.String()
if !scc.validDTMF.MatchString(digitsString) {
return nil, errors.New("sip.response.dtmf.create result of digits template contains invalid characters")
}
return SipDTMFResponse{
PreWait: scc.PreWait,
PostWait: scc.PostWait,
Digits: digitsString,
}, nil
}
func (scc *SipResponseDTMFCreate) Type() string {
return scc.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "sip.response.dtmf.create",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
preWait, ok := params["preWait"]
if !ok {
return nil, errors.New("sip.response.dtmf.create requires a preWait parameter")
}
preWaitNum, ok := preWait.(float64)
if !ok {
return nil, errors.New("sip.response.dtmf.create preWait must be a number")
}
postWait, ok := params["postWait"]
if !ok {
return nil, errors.New("sip.response.dtmf.create requires a postWait parameter")
}
postWaitNum, ok := postWait.(float64)
if !ok {
return nil, errors.New("sip.response.dtmf.create postWait must be a number")
}
digits, ok := params["digits"]
if !ok {
return nil, errors.New("sip.response.dtmf.create requires a digits parameter")
}
digitsString, ok := digits.(string)
if !ok {
return nil, errors.New("sip.response.dtmf.create digits must be a string")
}
digitsTemplate, err := template.New("digits").Parse(digitsString)
if err != nil {
return nil, err
}
return &SipResponseDTMFCreate{config: config, Digits: digitsTemplate, PreWait: int(preWaitNum), PostWait: int(postWaitNum), validDTMF: regexp.MustCompile(`^[0-9*#A-Da-d]+$`)}, nil
},
})
}

View File

@@ -1,14 +1,16 @@
package processing
package processor
import (
"bytes"
"context"
"fmt"
"errors"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
)
type StringCreate struct {
config ProcessorConfig
config config.ProcessorConfig
Template *template.Template
}
@@ -32,18 +34,18 @@ func (sc *StringCreate) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "string.create",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
tmpl, ok := params["template"]
if !ok {
return nil, fmt.Errorf("string.create requires a template parameter")
return nil, errors.New("string.create requires a template parameter")
}
templateString, ok := tmpl.(string)
if !ok {
return nil, fmt.Errorf("string.create template must be a string")
return nil, errors.New("string.create template must be a string")
}
templateTemplate, err := template.New("template").Parse(templateString)

View File

@@ -0,0 +1,187 @@
package processor_test
import (
"testing"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
type TestStruct struct {
Data string
}
func (t TestStruct) GetData() string {
return t.Data
}
func TestStringCreateFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["string.create"]
if !ok {
t.Fatalf("string.create processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "string.create",
Params: map[string]any{
"template": "{{.}}",
},
})
if err != nil {
t.Fatalf("failed to create string.create processor: %s", err)
}
if processorInstance.Type() != "string.create" {
t.Fatalf("string.create processor has wrong type: %s", processorInstance.Type())
}
payload := "hello"
expected := "hello"
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("string.create processing failed: %s", err)
}
if got != expected {
t.Fatalf("string.create got %+v, expected %+v", got, expected)
}
}
func TestGoodStringCreate(t *testing.T) {
tests := []struct {
name string
template string
payload any
expected string
}{
{
name: "string payload",
template: "{{.}}",
payload: "hello",
expected: "hello",
},
{
name: "number payload",
template: "{{.}}",
payload: 4,
expected: "4",
},
{
name: "boolean payload",
template: "{{.}}",
payload: true,
expected: "true",
},
{
name: "struct payload - field",
template: "{{.Data}}",
payload: TestStruct{Data: "test"},
expected: "test",
},
{
name: "struct payload - method",
template: "{{.GetData}}",
payload: TestStruct{Data: "test"},
expected: "test",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
template, err := template.New("template").Parse(test.template)
if err != nil {
t.Fatalf("string.create template parsing failed: %s", err)
}
processor := &processor.StringCreate{Template: template}
got, err := processor.Process(t.Context(), test.payload)
gotStrings, ok := got.(string)
if !ok {
t.Fatalf("string.create returned a %T payload: %s", got, got)
}
if err != nil {
t.Fatalf("string.create failed: %s", err)
}
if gotStrings != test.expected {
t.Fatalf("string.create got %s, expected %s", got, test.expected)
}
})
}
}
func TestBadStringCreate(t *testing.T) {
tests := []struct {
name string
params map[string]any
payload any
errorString string
}{
{
name: "no template param",
payload: "hello",
params: map[string]any{},
errorString: "string.create requires a template parameter",
},
{
name: "non string template",
payload: "hello",
params: map[string]any{
"template": 1,
},
errorString: "string.create template must be a string",
},
{
name: "invalid template",
payload: "hello",
params: map[string]any{
"template": "{{.",
},
errorString: "template: template:1: illegal number syntax: \".\"",
},
{
name: "bad property in template",
payload: "hello",
params: map[string]any{
"template": "{{.Invalid}}",
},
errorString: "template: template:1:2: executing \"template\" at <.Invalid>: can't evaluate field Invalid in type string",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registration, ok := processor.ProcessorRegistry["string.create"]
if !ok {
t.Fatalf("string.create processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "string.create",
Params: test.params,
})
if err != nil {
if test.errorString != err.Error() {
t.Fatalf("string.encode got error '%s', expected '%s'", err.Error(), test.errorString)
}
return
}
got, err := processorInstance.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("string.encode expected to fail but got payload: %s", got)
}
if err.Error() != test.errorString {
t.Fatalf("string.encode got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -1,19 +1,21 @@
package processing
package processor
import (
"context"
"fmt"
"errors"
"github.com/jwetzell/showbridge-go/internal/config"
)
type StringDecode struct {
config ProcessorConfig
config config.ProcessorConfig
}
func (sd *StringDecode) Process(ctx context.Context, payload any) (any, error) {
payloadBytes, ok := payload.([]byte)
if !ok {
return nil, fmt.Errorf("string.decode processor only accepts a []byte")
return nil, errors.New("string.decode processor only accepts a []byte")
}
payloadMessage := string(payloadBytes)
@@ -28,7 +30,7 @@ func (sd *StringDecode) Type() string {
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "string.decode",
New: func(config ProcessorConfig) (Processor, error) {
New: func(config config.ProcessorConfig) (Processor, error) {
return &StringDecode{config: config}, nil
},
})

Some files were not shown because too many files have changed in this diff Show More