160 Commits

Author SHA1 Message Date
Joel Wetzell
66f170047a remove arm images for now 2026-02-08 22:02:33 -06:00
Joel Wetzell
7ff20e0136 add more tests for string.split 2026-02-08 21:50:31 -06:00
Joel Wetzell
b53494fddc add more error tests for string.filter 2026-02-08 21:49:30 -06:00
Joel Wetzell
3688510ded fix test names 2026-02-08 21:49:12 -06:00
Joel Wetzell
d0bdc44c30 add test for string.filter 2026-02-08 21:40:53 -06:00
Joel Wetzell
1c49548a4c test cleanup 2026-02-08 21:38:44 -06:00
Joel Wetzell
4ea6c8f11f test cleanup 2026-02-08 21:31:08 -06:00
Joel Wetzell
0ef512eb28 add registry test for json.decode 2026-02-08 21:23:21 -06:00
Joel Wetzell
ebbce3261c add registry test for json.encode 2026-02-08 21:21:28 -06:00
Joel Wetzell
79fa925823 clean up json.encode test 2026-02-08 21:21:14 -06:00
Joel Wetzell
659c95102b Merge pull request #57 from jwetzell/feat/json-decode-processor
add json.decode processor
2026-02-08 21:15:55 -06:00
Joel Wetzell
e06c4195c7 add json.decode processor 2026-02-08 21:13:34 -06:00
Joel Wetzell
28e3c99ad8 add test loading from registry for time.sleep 2026-02-08 15:56:33 -06:00
Joel Wetzell
7312bfec20 add error tests for random processors 2026-02-08 15:56:14 -06:00
Joel Wetzell
4bbf69e644 add test for artnet.packet.filter 2026-02-08 15:55:48 -06:00
Joel Wetzell
1ba51446ad fix error messages in test 2026-02-08 15:55:32 -06:00
Joel Wetzell
2266d6a9b8 Merge pull request #56 from jwetzell/feat/json-schema
add json schema definitions for config
2026-02-08 15:04:25 -06:00
Joel Wetzell
f1f340f963 add schema for rest of processors 2026-02-08 13:34:40 -06:00
Joel Wetzell
4ca989280c switch interval and timer to new namespace 2026-02-08 09:45:22 -06:00
Joel Wetzell
ef83e84e84 add schema for time.sleep processor 2026-02-08 09:45:22 -06:00
Joel Wetzell
b8ad912855 add JSON schema for config file 2026-02-08 09:45:22 -06:00
Joel Wetzell
f766d0e2a4 Merge pull request #55 from jwetzell/feat/router-reload
reload router config on SIGHUP
2026-02-08 09:44:49 -06:00
Joel Wetzell
725e8ad670 add mutex for router operations 2026-02-07 17:07:18 -06:00
Joel Wetzell
7fa3b6d33d TODO 2026-02-07 16:29:49 -06:00
Joel Wetzell
5e957d7d03 expose running config from router 2026-02-07 16:28:06 -06:00
Joel Wetzell
bd89df3da2 create app struct 2026-02-07 14:15:24 -06:00
Joel Wetzell
914f6aa833 mess around with being able to reload router 2026-02-07 13:42:55 -06:00
Joel Wetzell
2104d9f1a9 change to start/stop not run/stop 2026-02-07 12:51:01 -06:00
Joel Wetzell
457ff83c23 add more convenience methods for start/stopping modules on router 2026-02-07 12:48:27 -06:00
Joel Wetzell
37c7a23063 add debug log at beginning of running 2026-02-07 12:40:30 -06:00
Joel Wetzell
9ba3a88e8c add removeModule function to router 2026-02-07 09:53:48 -06:00
Joel Wetzell
33ecc94097 add Stop function to module 2026-02-07 09:53:38 -06:00
Joel Wetzell
8f5091cf9b load router from context in mock module 2026-02-05 20:20:00 -06:00
Joel Wetzell
b095419b6e switch module instances to map on id 2026-02-05 20:02:03 -06:00
Joel Wetzell
1e872ba0b9 fix RouteIO interface 2026-02-05 19:51:09 -06:00
Joel Wetzell
b959b88527 split out module and route creation in router and router output only does one module 2026-02-05 19:31:31 -06:00
Joel Wetzell
0dfbf30fa7 Merge pull request #54 from jwetzell/feat/opentelemetry
add option to export open telemetry tracing
2026-02-04 19:22:15 -06:00
Joel Wetzell
abff674a75 fix router test 2026-02-04 19:17:57 -06:00
Joel Wetzell
a920f34926 rework route and processing spans 2026-02-04 19:01:18 -06:00
Joel Wetzell
4dac1faae0 add basic opentelemetry 2026-02-04 19:01:18 -06:00
Joel Wetzell
ab5b40ee72 remove osc.message.transform 2026-02-04 18:39:16 -06:00
Joel Wetzell
5a29139e63 Merge pull request #53 from jwetzell/chore/testing
add more testing
2026-02-04 18:38:18 -06:00
Joel Wetzell
1587f84981 add test for uint.random processor 2026-02-04 18:30:51 -06:00
Joel Wetzell
0cb13a30f9 add test for int.random processor 2026-02-04 18:30:41 -06:00
Joel Wetzell
8212d15a70 add test for module with no id 2026-02-04 18:30:25 -06:00
Joel Wetzell
eee356225f Merge pull request #52 from jwetzell/fix/debug-log-payload-string
more compatible printing of payload in debug.log
2026-02-03 22:52:20 -06:00
Joel Wetzell
5cd02f9e3a more compatible printing of payload in debug.log 2026-02-03 22:50:30 -06:00
Joel Wetzell
100edab869 Merge pull request #51 from jwetzell/fix/no-default-framing-method
don't default framing method
2026-02-03 22:28:47 -06:00
Joel Wetzell
fd4c2cb59b make framing parameter required in net.tcp.client 2026-02-03 22:27:58 -06:00
Joel Wetzell
b6ee3c68f1 don't default framing method 2026-02-03 22:26:16 -06:00
Joel Wetzell
7e0c2b2560 Merge pull request #48 from jwetzell/dependabot/github_actions/docker/login-action-3.7.0
Bump docker/login-action from 3.6.0 to 3.7.0
2026-02-03 11:35:37 -06:00
Joel Wetzell
c9a3eb80bc Merge pull request #50 from jwetzell/dependabot/go_modules/github.com/emiago/diago-0.26.2
Bump github.com/emiago/diago from 0.25.0 to 0.26.2
2026-02-03 11:35:13 -06:00
dependabot[bot]
2a5feec07a Bump github.com/emiago/diago from 0.25.0 to 0.26.2
Bumps [github.com/emiago/diago](https://github.com/emiago/diago) from 0.25.0 to 0.26.2.
- [Release notes](https://github.com/emiago/diago/releases)
- [Commits](https://github.com/emiago/diago/compare/v0.25.0...v0.26.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-02 23:54:12 +00:00
dependabot[bot]
ced4bc839e Bump docker/login-action from 3.6.0 to 3.7.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.6.0 to 3.7.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](5e57cd1181...c94ce9fb46)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-02 23:53:47 +00:00
Joel Wetzell
ce6a9ff4a8 update labeler 2026-02-02 13:14:11 -06:00
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
83 changed files with 6386 additions and 767 deletions

4
.github/labeler.yml vendored
View File

@@ -14,6 +14,10 @@ processor:
- changed-files: - changed-files:
- any-glob-to-any-file: 'internal/processor/**' - any-glob-to-any-file: 'internal/processor/**'
router:
- changed-files:
- any-glob-to-any-file: 'router*'
route: route:
- changed-files: - changed-files:
- any-glob-to-any-file: 'internal/route/**' - any-glob-to-any-file: 'internal/route/**'

5
.github/release.yml vendored
View File

@@ -17,12 +17,13 @@ changelog:
- title: Processor 🏭 - title: Processor 🏭
labels: labels:
- processor - processor
- title: Route 🛣️ - title: Routing 🛣️
labels: labels:
- router
- route - route
- title: CLI ⌨️ - title: CLI ⌨️
labels: labels:
- cmd - cli
- title: Other Changes - title: Other Changes
labels: labels:
- '*' - '*'

View File

@@ -40,10 +40,10 @@ jobs:
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -65,4 +65,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64

2
.vscode/launch.json vendored
View File

@@ -11,7 +11,7 @@
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "cmd/showbridge", "program": "cmd/showbridge",
"args": ["--debug"], "args": ["--log-level", "debug"],
"cwd": "./" "cwd": "./"
} }
] ]

View File

@@ -6,29 +6,37 @@ Simple protocol router _/s_
</div> </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 ### Supported Protocols
- HTTP - HTTP
- client
- server
- UDP - UDP
- client
- server
- TCP - TCP
- client
- server
- [MQTT](https://mqtt.org/) - [MQTT](https://mqtt.org/)
- client
- [NATS](https://nats.io/) - [NATS](https://nats.io/)
- client
- [PosiStageNet](https://posistage.net/) - [PosiStageNet](https://posistage.net/)
- client
- MIDI (not included in pre-built binaries yet) - MIDI (not included in pre-built binaries yet)
- input - Serial (not included in pre-built binaries yet)
- output - [OSC](https://opensoundcontrol.stanford.edu/spec-1_0.html)
- [FreeD](https://ptzoptics.com/freed/)
- [SIP](https://en.wikipedia.org/wiki/Session_Initiation_Protocol) - [SIP](https://en.wikipedia.org/wiki/Session_Initiation_Protocol)
- call server
- [DTMF](https://en.wikipedia.org/wiki/Dual-tone_multi-frequency_signaling) server
### 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

@@ -3,18 +3,29 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"os" "os"
"os/signal" "os/signal"
"slices"
"sync"
"syscall"
"github.com/jwetzell/showbridge-go" "github.com/jwetzell/showbridge-go"
"github.com/jwetzell/showbridge-go/internal/config" "github.com/jwetzell/showbridge-go/internal/config"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/trace"
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
) )
var ( var (
version = "dev" version = "dev"
sigHangup = make(chan os.Signal, 1)
) )
func main() { func main() {
@@ -28,65 +39,42 @@ func main() {
Value: "./config.yaml", Value: "./config.yaml",
Usage: "path to config file", Usage: "path to config file",
}, },
&cli.BoolFlag{ &cli.StringFlag{
Name: "debug", Name: "log-level",
Value: false, Value: "info",
Usage: "set log level to DEBUG", Usage: "set log level",
}, Validator: func(level string) error {
&cli.BoolFlag{ levels := []string{"debug", "info", "warn", "error"}
Name: "json", if !slices.Contains(levels, level) {
Value: false, return fmt.Errorf("unknown log level: %s", level)
Usage: "log using JSON",
},
},
Action: func(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
if c.Bool("debug") {
logLevel = slog.LevelDebug
}
logHandlerOptions := &slog.HandlerOptions{
Level: logLevel,
}
logOutput := os.Stderr
var logHandler slog.Handler = slog.NewTextHandler(logOutput, logHandlerOptions)
if c.Bool("json") {
logHandler = slog.NewJSONHandler(logOutput, logHandlerOptions)
}
logger := slog.New(logHandler)
slog.SetDefault(logger)
router, moduleErrors, routeErrors := showbridge.NewRouter(ctx, config)
for _, moduleError := range moduleErrors {
logger.Error("problem initializing module", "index", moduleError.Index, "error", moduleError.Error)
}
for _, routeError := range routeErrors {
logger.Error("problem initializing route", "index", routeError.Index, "error", routeError.Error)
}
router.Run()
return nil 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
},
},
&cli.BoolFlag{
Name: "trace",
Value: false,
Usage: "enable OpenTelemetry tracing",
},
},
Action: run,
} }
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Interrupt) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
signal.Notify(sigHangup, syscall.SIGHUP)
defer cancel() defer cancel()
err := cmd.Run(ctx, os.Args) err := cmd.Run(ctx, os.Args)
@@ -96,6 +84,16 @@ func main() {
} }
type showbridgeApp struct {
ctx context.Context
configPath string
logger *slog.Logger
router *showbridge.Router
routerRunner *sync.WaitGroup
tracer trace.Tracer
routerMutex sync.Mutex
}
func readConfig(configPath string) (config.Config, error) { func readConfig(configPath string) (config.Config, error) {
cfg := config.Config{} cfg := config.Config{}
@@ -112,3 +110,162 @@ func readConfig(configPath string) (config.Config, error) {
return cfg, nil return cfg, nil
} }
func run(ctx context.Context, c *cli.Command) error {
configPath := c.String("config")
if configPath == "" {
return errors.New("config path cannot be empty")
}
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))
var tracer trace.Tracer
if c.Bool("trace") {
exporter, err := otlptracehttp.New(ctx)
if err != nil {
return fmt.Errorf("failed to create trace exporter: %w", err)
}
tracerProvider := newTracerProvider(exporter)
otel.SetTracerProvider(tracerProvider)
defer tracerProvider.Shutdown(ctx)
tracer = tracerProvider.Tracer("showbridge")
} else {
tracer = otel.Tracer("showbridge")
}
showbridgeApp := &showbridgeApp{
ctx: ctx,
configPath: configPath,
logger: slog.Default().With("component", "cmd"),
routerRunner: &sync.WaitGroup{},
tracer: tracer,
}
router, err := showbridgeApp.getNewRouter()
if err != nil {
return fmt.Errorf("failed to initialize router: %w", err)
}
showbridgeApp.routerMutex.Lock()
showbridgeApp.router = router
showbridgeApp.routerRunner.Go(func() {
router.Start(context.Background())
})
showbridgeApp.routerMutex.Unlock()
go showbridgeApp.handleHangup()
<-showbridgeApp.ctx.Done()
showbridgeApp.logger.Debug("shutting down router")
showbridgeApp.router.Stop()
showbridgeApp.logger.Debug("waiting for router to exit")
showbridgeApp.routerRunner.Wait()
return nil
}
func (app *showbridgeApp) handleHangup() {
for {
select {
case <-sigHangup:
app.logger.Info("received SIGHUP, reloading configuration")
newRouter, err := app.getNewRouter()
if err != nil {
app.logger.Error("failed to reload configuration", "error", err)
continue
}
app.routerMutex.Lock()
app.router.Stop()
app.routerRunner.Wait()
app.router = newRouter
app.routerRunner.Go(func() {
app.router.Start(context.Background())
})
app.logger.Info("configuration reloaded successfully")
app.routerMutex.Unlock()
case <-app.ctx.Done():
return
}
}
}
func (app *showbridgeApp) getNewRouter() (*showbridge.Router, error) {
// TODO(jwetzell): what should happen when the config file is unchanged?
config, err := readConfig(app.configPath)
if err != nil {
return nil, err
}
router, moduleErrors, routeErrors := showbridge.NewRouter(config, app.tracer)
for _, moduleError := range moduleErrors {
app.logger.Error("problem initializing module", "index", moduleError.Index, "error", moduleError.Error)
}
for _, routeError := range routeErrors {
app.logger.Error("problem initializing route", "index", routeError.Index, "error", routeError.Error)
}
if moduleErrors != nil || routeErrors != nil {
return nil, fmt.Errorf("errors initializing modules or routes")
}
return router, nil
}
func newTracerProvider(exp sdktrace.SpanExporter) *sdktrace.TracerProvider {
r, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("showbridge"),
semconv.ServiceVersion(version),
),
)
if err != nil {
panic(err)
}
return sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(r),
)
}

47
go.mod
View File

@@ -1,55 +1,82 @@
module github.com/jwetzell/showbridge-go module github.com/jwetzell/showbridge-go
go 1.25.3 go 1.25.5
require ( require (
github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/emiago/diago v0.23.1-0.20251211215055-e1d875617111 github.com/emiago/diago v0.26.2
github.com/emiago/sipgo v1.0.1 github.com/emiago/sipgo v1.1.2
github.com/expr-lang/expr v1.17.7 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/free-d-go v0.1.0
github.com/jwetzell/osc-go v0.1.0 github.com/jwetzell/osc-go v0.1.0
github.com/jwetzell/psn-go v0.3.0 github.com/jwetzell/psn-go v0.3.0
github.com/nats-io/nats.go v1.48.0 github.com/nats-io/nats.go v1.48.0
github.com/urfave/cli/v3 v3.6.1 github.com/urfave/cli/v3 v3.6.2
gitlab.com/gomidi/midi/v2 v2.3.18 gitlab.com/gomidi/midi/v2 v2.3.18
go.bug.st/serial v1.6.4 go.bug.st/serial v1.6.4
modernc.org/quickjs v0.17.0 go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
modernc.org/quickjs v0.17.1
sigs.k8s.io/yaml v1.6.0 sigs.k8s.io/yaml v1.6.0
) )
require ( require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/creack/goselect v0.1.2 // indirect github.com/creack/goselect v0.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect
github.com/emiago/dtls/v3 v3.0.0-20260122183559-8b8d23e359c0 // indirect
github.com/go-audio/riff v1.0.0 // indirect github.com/go-audio/riff v1.0.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0 // indirect
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect
github.com/icholy/digest v1.1.0 // indirect github.com/icholy/digest v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // 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/logging v0.2.4 // indirect
github.com/pion/randutil v0.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.8.26 // indirect github.com/pion/rtp v1.8.26 // indirect
github.com/pion/srtp/v3 v3.0.9 // indirect github.com/pion/srtp/v3 v3.0.9 // indirect
github.com/pion/transport/v3 v3.1.1 // indirect github.com/pion/transport/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 github.com/zaf/g711 v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.42.0 // 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/net v0.44.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 // indirect gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 // indirect
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.67.1 // indirect
modernc.org/libquickjs v0.12.2 // indirect modernc.org/libquickjs v0.12.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )

107
go.sum
View File

@@ -1,19 +1,36 @@
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/emiago/diago v0.23.1-0.20251211215055-e1d875617111 h1:jqhOZbH40pf2jiUhGaBNk334wOtNYvAaXg/mHOXhy/Y= github.com/emiago/diago v0.26.2 h1:3QL03V0drX96eIBFBpfueNcywydRgYqffKihluGL0gA=
github.com/emiago/diago v0.23.1-0.20251211215055-e1d875617111/go.mod h1:3vLCCq8/G/Ei5I64IHtrmBTag+nPLcgXcKeN1KkLtuc= github.com/emiago/diago v0.26.2/go.mod h1:jZ+7EnKcmgqKnLjCHPqfbP4Y/9Q/JLSLxMflDrp2J1M=
github.com/emiago/sipgo v1.0.1 h1:8eCZ6L/VX3isyByyv1RrBoQ5GyBoRXBHkNMYjwacRfk= github.com/emiago/dtls/v3 v3.0.0-20260122183559-8b8d23e359c0 h1:o4LxpUnZ1zxiQ+Qjc9kLwXcjz31NGAHmnZ7xoJto3VM=
github.com/emiago/sipgo v1.0.1/go.mod h1:DuwAxBZhKMqIzQFPGZb1MVAGU6Wuxj64oTOhd5dx/FY= github.com/emiago/dtls/v3 v3.0.0-20260122183559-8b8d23e359c0/go.mod h1:ydcZ977eS1I6uOWodzMuw30BwvNAzT9su/xcNYSJqjA=
github.com/emiago/sipgo v1.1.2 h1:JvLqEvqNSQm2mBX40qZ7O0WC3Ee67Z0UrfmBI7y6Beo=
github.com/emiago/sipgo v1.1.2/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 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= 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 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 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 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
@@ -26,10 +43,16 @@ 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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0 h1:CWyXh/jylQWp2dtiV33mY4iSSp6yf4lmn+c7/tN+ObI=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.21.0/go.mod h1:nCLIt0w3Ept2NwF8ThLmrppXsfT07oC8k0XNDxd8sVU=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 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/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 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= 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 h1:xHt6dvyit98X+OC3jVzV0aLidxbyzi3vI9QiYkteEtA=
github.com/jwetzell/free-d-go v0.1.0/go.mod h1:KmrkooRARRaxJTBSPvwt/6IMAIaHH1R8bSA8cwbbELw= 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 h1:EXxup5VWBErHot2Ri4MFToPf6KCzLDTbCt2x6GLfw8I=
@@ -50,8 +73,8 @@ 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/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 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= 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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= 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 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
@@ -64,41 +87,77 @@ 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/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 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 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/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/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 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 h1:XZYkjjiAg9QTBnHqEg37m2I9q3IIDv5JRYXs2N8ma7c=
github.com/zaf/g711 v1.4.0/go.mod h1:eCDXt3dSp/kYYAoooba7ukD/Q75jvAaS4WOMr0l1Roo= 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 h1:sj2fOhtvOe+zI8YJe8qTxLw5zv0ntULLUDwcFOaZQbI=
gitlab.com/gomidi/midi/v2 v2.3.18/go.mod h1:jDpP4O4skYi+7iVwt6Zyp18bd2M4hkjtMuw2cmgKgfw= 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 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 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/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 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= 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 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= 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-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 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 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -120,18 +179,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/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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libquickjs v0.12.2 h1:XtF6iD+aoN/pEz1MAQjg1wMZT412pzmOlO11UGLW/wQ= modernc.org/libquickjs v0.12.3 h1:2IU9B6njBmce2PuYttJDkXeoLRV9WnvgP+eU5HAC8YI=
modernc.org/libquickjs v0.12.2/go.mod h1:n+vyuJ4mXh1pQYt1bJttvoCE+2t+MqCM8BFlcSDg/70= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 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 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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.1 h1:CbYnbTf7ksZk9YZ1rRM2Ab1Zfi+X6s50kXiOhpd2NIg=
modernc.org/quickjs v0.17.0/go.mod h1:TE+wpAYX4V7Nvi/sHRUrd9l5L176ZIiS0as3MMObvC0= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=

View File

@@ -1,9 +1,5 @@
package framer package framer
import (
"fmt"
)
type Framer interface { type Framer interface {
Decode([]byte) [][]byte Decode([]byte) [][]byte
Encode([]byte) []byte Encode([]byte) []byte
@@ -11,19 +7,19 @@ type Framer interface {
Buffer() []byte Buffer() []byte
} }
func GetFramer(framingType string) (Framer, error) { func GetFramer(framingType string) Framer {
switch framingType { switch framingType {
case "CR": case "CR":
return NewByteSeparatorFramer([]byte{'\r'}), nil return NewByteSeparatorFramer([]byte{'\r'})
case "LF": case "LF":
return NewByteSeparatorFramer([]byte{'\n'}), nil return NewByteSeparatorFramer([]byte{'\n'})
case "CRLF": case "CRLF":
return NewByteSeparatorFramer([]byte{'\r', '\n'}), nil return NewByteSeparatorFramer([]byte{'\r', '\n'})
case "SLIP": case "SLIP":
return NewSlipFramer(), nil return NewSlipFramer()
case "RAW": case "RAW":
return NewRawFramer(), nil return NewRawFramer()
default: default:
return nil, fmt.Errorf("unknown framing method: %s", framingType) 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)
}
}

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

@@ -7,7 +7,7 @@ import (
"github.com/jwetzell/showbridge-go/internal/framer" "github.com/jwetzell/showbridge-go/internal/framer"
) )
func TestGoodSeparatorFramer(t *testing.T) { func TestGoodSeparatorFramerDecode(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
framer framer.Framer framer framer.Framer
@@ -17,7 +17,7 @@ func TestGoodSeparatorFramer(t *testing.T) {
}{ }{
{ {
name: "new line separator", name: "new line separator",
framer: framer.NewByteSeparatorFramer([]byte{0x0a}), framer: framer.GetFramer("LF"),
input: []byte("Hello\nWorld\nThis is a test\n"), input: []byte("Hello\nWorld\nThis is a test\n"),
expected: [][]byte{ expected: [][]byte{
[]byte("Hello"), []byte("Hello"),
@@ -26,9 +26,20 @@ func TestGoodSeparatorFramer(t *testing.T) {
}, },
buffer: []byte{}, 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", name: "CRLF separator",
framer: framer.NewByteSeparatorFramer([]byte{0x0d, 0x0a}), framer: framer.GetFramer("CRLF"),
input: []byte("Hello\r\nWorld\r\nThis is a test\r\n"), input: []byte("Hello\r\nWorld\r\nThis is a test\r\n"),
expected: [][]byte{ expected: [][]byte{
[]byte("Hello"), []byte("Hello"),
@@ -39,7 +50,7 @@ func TestGoodSeparatorFramer(t *testing.T) {
}, },
{ {
name: "extra data after separator", name: "extra data after separator",
framer: framer.NewByteSeparatorFramer([]byte{0x0d, 0x0a}), framer: framer.GetFramer("CRLF"),
input: []byte("Hello\r\nWorld\r\nThis is a test\r\nextra"), input: []byte("Hello\r\nWorld\r\nThis is a test\r\nextra"),
expected: [][]byte{ expected: [][]byte{
[]byte("Hello"), []byte("Hello"),
@@ -54,7 +65,7 @@ func TestGoodSeparatorFramer(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
frames := test.framer.Decode(test.input) frames := test.framer.Decode(test.input)
if len(frames) != len(test.expected) { if len(frames) != len(test.expected) {
t.Errorf("separator framer got %d frames, expected %d", len(frames), len(test.expected)) t.Fatalf("separator framer got %d frames, expected %d", len(frames), len(test.expected))
} }
for i, frame := range frames { for i, frame := range frames {
if !slices.Equal(frame, test.expected[i]) { if !slices.Equal(frame, test.expected[i]) {
@@ -62,8 +73,58 @@ func TestGoodSeparatorFramer(t *testing.T) {
} }
} }
if !slices.Equal(test.framer.Buffer(), test.buffer) { if !slices.Equal(test.framer.Buffer(), test.buffer) {
t.Errorf("separator framer buffer got %s, expected %s", 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

@@ -59,9 +59,9 @@ func (sf *SlipFramer) Encode(data []byte) []byte {
for _, byteToEncode := range data { for _, byteToEncode := range data {
switch byteToEncode { switch byteToEncode {
case END: case END:
encodedBytes = append(encodedBytes, ESC_END) encodedBytes = append(encodedBytes, ESC, ESC_END)
case ESC: case ESC:
encodedBytes = append(encodedBytes, ESC_ESC) encodedBytes = append(encodedBytes, ESC, ESC_ESC)
default: default:
encodedBytes = append(encodedBytes, byteToEncode) encodedBytes = append(encodedBytes, byteToEncode)
} }

View File

@@ -7,7 +7,7 @@ import (
"github.com/jwetzell/showbridge-go/internal/framer" "github.com/jwetzell/showbridge-go/internal/framer"
) )
func TestGoodSLIPFramer(t *testing.T) { func TestGoodSLIPFramerDecode(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
framer framer.Framer framer framer.Framer
@@ -17,20 +17,34 @@ func TestGoodSLIPFramer(t *testing.T) {
}{ }{
{ {
name: "OSC SLIP messages", name: "OSC SLIP messages",
framer: framer.NewSlipFramer(), framer: framer.GetFramer("SLIP"),
input: []byte{0xc0, 0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0xc0}, input: []byte{0xc0, 0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0xc0},
expected: [][]byte{ expected: [][]byte{
{0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00}, {0x2f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00},
}, },
buffer: []byte{}, 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
frames := test.framer.Decode(test.input) frames := test.framer.Decode(test.input)
if len(frames) != len(test.expected) { if len(frames) != len(test.expected) {
t.Errorf("SLIP framer got %d frames, expected %d", len(frames), len(test.expected)) t.Fatalf("SLIP framer got %d frames, expected %d", len(frames), len(test.expected))
} }
for i, frame := range frames { for i, frame := range frames {
if !slices.Equal(frame, test.expected[i]) { if !slices.Equal(frame, test.expected[i]) {
@@ -38,8 +52,59 @@ func TestGoodSLIPFramer(t *testing.T) {
} }
} }
if !slices.Equal(test.framer.Buffer(), test.buffer) { if !slices.Equal(test.framer.Buffer(), test.buffer) {
t.Errorf("SLIP framer buffer got %s, expected %s", 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

@@ -17,14 +17,15 @@ type HTTPClient struct {
client *http.Client client *http.Client
router route.RouteIO router route.RouteIO
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "http.client", Type: "http.client",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
return &HTTPClient{config: config, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &HTTPClient{config: config, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -37,18 +38,28 @@ func (hc *HTTPClient) Type() string {
return hc.config.Type return hc.config.Type
} }
func (hc *HTTPClient) Run() error { func (hc *HTTPClient) Start(ctx context.Context) error {
hc.logger.Debug("running")
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("http.client unable to get router from context")
}
hc.router = router
moduleContext, cancel := context.WithCancel(ctx)
hc.ctx = moduleContext
hc.cancel = cancel
hc.client = &http.Client{ hc.client = &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
} }
<-hc.ctx.Done() <-hc.ctx.Done()
hc.logger.Debug("router context done in module") hc.logger.Debug("done")
return nil return nil
} }
func (hc *HTTPClient) Output(payload any) error { func (hc *HTTPClient) Output(ctx context.Context, payload any) error {
payloadRequest, ok := payload.(*http.Request) payloadRequest, ok := payload.(*http.Request)
@@ -67,8 +78,12 @@ func (hc *HTTPClient) Output(payload any) error {
} }
if hc.router != nil { if hc.router != nil {
hc.router.HandleInput(hc.Id(), response) hc.router.HandleInput(hc.ctx, hc.Id(), response)
} }
return nil return nil
} }
func (hc *HTTPClient) Stop() {
hc.cancel()
}

View File

@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"github.com/jwetzell/showbridge-go/internal/config" "github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
"github.com/jwetzell/showbridge-go/internal/route" "github.com/jwetzell/showbridge-go/internal/route"
) )
@@ -18,17 +19,43 @@ type HTTPServer struct {
ctx context.Context ctx context.Context
router route.RouteIO router route.RouteIO
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
type ResponseData struct { type ResponseIOError struct {
Index int `json:"index"`
OutputError *string `json:"outputError"`
ProcessError *string `json:"processError"`
InputError *string `json:"inputError"`
}
type IOResponseData struct {
IOErrors []ResponseIOError `json:"ioErrors"`
Message string `json:"message"` Message string `json:"message"`
Status string `json:"status"` 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() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "http.server", Type: "http.server",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
port, ok := params["port"] port, ok := params["port"]
if !ok { if !ok {
@@ -41,7 +68,7 @@ func init() {
return nil, errors.New("http.server port must be uint16") return nil, errors.New("http.server port must be uint16")
} }
return &HTTPServer{Port: uint16(portNum), config: config, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &HTTPServer{Port: uint16(portNum), config: config, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -54,57 +81,129 @@ func (hs *HTTPServer) Type() string {
return hs.config.Type return hs.config.Type
} }
func (hs *HTTPServer) HandleDefault(w http.ResponseWriter, r *http.Request) { func (hs *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") responseWriter := HTTPServerResponseWriter{ResponseWriter: w}
response := ResponseData{ response := IOResponseData{
Message: "routing successful", Message: "routing successful",
Status: "ok", Status: "ok",
} }
if hs.router != nil { if hs.router != nil {
routingErrors := hs.router.HandleInput(hs.Id(), r) 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 { if routingErrors != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
response.Status = "error" response.Status = "error"
response.Message = "routing failed" 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.OutputError != nil {
errorMsg := responseIOError.OutputError.Error()
errorToAdd.OutputError = &errorMsg
}
response.IOErrors = append(response.IOErrors, errorToAdd)
}
json.NewEncoder(w).Encode(response)
return
} else { } else {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
response.Message = "routing successful" 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 { } else {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
response.Message = "no router registered" response.Message = "no router registered"
response.Status = "error" response.Status = "error"
}
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
return
}
} }
func (hs *HTTPServer) Run() error { func (hs *HTTPServer) Start(ctx context.Context) error {
http.HandleFunc("/", hs.HandleDefault) hs.logger.Debug("running")
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("http.server unable to get router from context")
}
hs.router = router
moduleContext, cancel := context.WithCancel(ctx)
hs.ctx = moduleContext
hs.cancel = cancel
httpServer := &http.Server{ httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", hs.Port), Addr: fmt.Sprintf(":%d", hs.Port),
Handler: http.DefaultServeMux, Handler: hs,
} }
go func() { go func() {
<-hs.ctx.Done() <-hs.ctx.Done()
hs.logger.Debug("router context done in module")
httpServer.Close() httpServer.Close()
}() }()
err := httpServer.ListenAndServe() err := httpServer.ListenAndServe()
// TODO(jwetzell): handle server closed error differently // TODO(jwetzell): handle server closed error differently
if err != nil { if err != nil {
if err.Error() != "http: Server closed" {
return err return err
} }
}
<-hs.ctx.Done() <-hs.ctx.Done()
hs.logger.Debug("done")
return nil return nil
} }
func (hs *HTTPServer) Output(payload any) error { func (hs *HTTPServer) Output(ctx context.Context, payload any) error {
return errors.New("http.server output is not implemented") 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
}
func (hs *HTTPServer) Stop() {
hs.cancel()
} }

View File

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

View File

@@ -21,12 +21,13 @@ type MIDIInput struct {
Port string Port string
SendFunc func(midi.Message) error SendFunc func(midi.Message) error
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "midi.input", Type: "midi.input",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
port, ok := params["port"] port, ok := params["port"]
@@ -40,7 +41,7 @@ func init() {
return nil, errors.New("midi.input port must be a string") return nil, errors.New("midi.input port must be a string")
} }
return &MIDIInput{config: config, Port: portString, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &MIDIInput{config: config, Port: portString, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -53,8 +54,18 @@ func (mi *MIDIInput) Type() string {
return mi.config.Type return mi.config.Type
} }
func (mi *MIDIInput) Run() error { func (mi *MIDIInput) Start(ctx context.Context) error {
mi.logger.Debug("running")
defer midi.CloseDriver() 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
moduleContext, cancel := context.WithCancel(ctx)
mi.ctx = moduleContext
mi.cancel = cancel
in, err := midi.FindInPort(mi.Port) in, err := midi.FindInPort(mi.Port)
if err != nil { if err != nil {
@@ -63,7 +74,7 @@ func (mi *MIDIInput) Run() error {
stop, err := midi.ListenTo(in, func(msg midi.Message, timestampms int32) { stop, err := midi.ListenTo(in, func(msg midi.Message, timestampms int32) {
if mi.router != nil { if mi.router != nil {
mi.router.HandleInput(mi.Id(), msg) mi.router.HandleInput(mi.ctx, mi.Id(), msg)
} }
}, midi.UseSysEx()) }, midi.UseSysEx())
@@ -74,10 +85,14 @@ func (mi *MIDIInput) Run() error {
defer stop() defer stop()
<-mi.ctx.Done() <-mi.ctx.Done()
mi.logger.Debug("router context done in module") mi.logger.Debug("done")
return nil return nil
} }
func (mi *MIDIInput) Output(payload any) error { func (mi *MIDIInput) Output(ctx context.Context, payload any) error {
return errors.New("midi.input output is not implemented") return errors.New("midi.input output is not implemented")
} }
func (mi *MIDIInput) Stop() {
mi.cancel()
}

View File

@@ -21,12 +21,13 @@ type MIDIOutput struct {
Port string Port string
SendFunc func(midi.Message) error SendFunc func(midi.Message) error
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "midi.output", Type: "midi.output",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
port, ok := params["port"] port, ok := params["port"]
@@ -41,7 +42,7 @@ func init() {
return nil, errors.New("midi.output port must be a string") return nil, errors.New("midi.output port must be a string")
} }
return &MIDIOutput{config: config, Port: portString, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &MIDIOutput{config: config, Port: portString, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -54,8 +55,18 @@ func (mo *MIDIOutput) Type() string {
return mo.config.Type return mo.config.Type
} }
func (mo *MIDIOutput) Run() error { func (mo *MIDIOutput) Start(ctx context.Context) error {
mo.logger.Debug("running")
defer midi.CloseDriver() 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
moduleContext, cancel := context.WithCancel(ctx)
mo.ctx = moduleContext
mo.cancel = cancel
out, err := midi.FindOutPort(mo.Port) out, err := midi.FindOutPort(mo.Port)
@@ -71,11 +82,11 @@ func (mo *MIDIOutput) Run() error {
mo.SendFunc = send mo.SendFunc = send
<-mo.ctx.Done() <-mo.ctx.Done()
mo.logger.Debug("router context done in module") mo.logger.Debug("done")
return nil return nil
} }
func (mo *MIDIOutput) Output(payload any) error { func (mo *MIDIOutput) Output(ctx context.Context, payload any) error {
if mo.SendFunc == nil { if mo.SendFunc == nil {
return errors.New("midi.output output is not setup") return errors.New("midi.output output is not setup")
} }
@@ -88,3 +99,7 @@ func (mo *MIDIOutput) Output(payload any) error {
return mo.SendFunc(payloadMessage) return mo.SendFunc(payloadMessage)
} }
func (mo *MIDIOutput) Stop() {
mo.cancel()
}

View File

@@ -3,10 +3,10 @@ package module
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"sync" "sync"
"github.com/jwetzell/showbridge-go/internal/config" "github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
) )
type ModuleError struct { type ModuleError struct {
@@ -18,13 +18,14 @@ type ModuleError struct {
type Module interface { type Module interface {
Id() string Id() string
Type() string Type() string
Run() error Start(context.Context) error
Output(any) error Stop()
Output(context.Context, any) error
} }
type ModuleRegistration struct { type ModuleRegistration struct {
Type string `json:"type"` Type string `json:"type"`
New func(context.Context, config.ModuleConfig, route.RouteIO) (Module, error) New func(config.ModuleConfig) (Module, error)
} }
func RegisterModule(mod ModuleRegistration) { func RegisterModule(mod ModuleRegistration) {
@@ -49,3 +50,7 @@ var (
moduleRegistryMu sync.RWMutex 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

@@ -3,7 +3,6 @@ package module
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
mqtt "github.com/eclipse/paho.mqtt.golang" mqtt "github.com/eclipse/paho.mqtt.golang"
@@ -20,12 +19,13 @@ type MQTTClient struct {
Topic string Topic string
client mqtt.Client client mqtt.Client
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "mqtt.client", Type: "mqtt.client",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
broker, ok := params["broker"] broker, ok := params["broker"]
@@ -63,7 +63,7 @@ func init() {
return nil, errors.New("mqtt.client clientId must be string") return nil, errors.New("mqtt.client clientId must be string")
} }
return &MQTTClient{config: config, Broker: brokerString, Topic: topicString, ClientID: clientIdString, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &MQTTClient{config: config, Broker: brokerString, Topic: topicString, ClientID: clientIdString, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -76,7 +76,18 @@ func (mc *MQTTClient) Type() string {
return mc.config.Type return mc.config.Type
} }
func (mc *MQTTClient) Run() error { func (mc *MQTTClient) Start(ctx context.Context) error {
mc.logger.Debug("running")
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("mqtt.client unable to get router from context")
}
mc.router = router
moduleContext, cancel := context.WithCancel(ctx)
mc.ctx = moduleContext
mc.cancel = cancel
opts := mqtt.NewClientOptions() opts := mqtt.NewClientOptions()
opts.AddBroker(mc.Broker) opts.AddBroker(mc.Broker)
opts.SetClientID(mc.ClientID) opts.SetClientID(mc.ClientID)
@@ -85,12 +96,13 @@ func (mc *MQTTClient) Run() error {
opts.OnConnect = func(c mqtt.Client) { opts.OnConnect = func(c mqtt.Client) {
token := mc.client.Subscribe(mc.Topic, 1, func(c mqtt.Client, m mqtt.Message) { token := mc.client.Subscribe(mc.Topic, 1, func(c mqtt.Client, m mqtt.Message) {
mc.router.HandleInput(mc.Id(), m) mc.router.HandleInput(mc.ctx, mc.Id(), m)
}) })
token.Wait() token.Wait()
} }
mc.client = mqtt.NewClient(opts) mc.client = mqtt.NewClient(opts)
defer mc.client.Disconnect(250)
token := mc.client.Connect() token := mc.client.Connect()
@@ -101,15 +113,13 @@ func (mc *MQTTClient) Run() error {
} }
<-mc.ctx.Done() <-mc.ctx.Done()
mc.logger.Debug("router context done in module") mc.logger.Debug("done")
return nil return nil
} }
func (mc *MQTTClient) Output(payload any) error { func (mc *MQTTClient) Output(ctx context.Context, payload any) error {
payloadMessage, ok := payload.(mqtt.Message) payloadMessage, ok := payload.(mqtt.Message)
fmt.Printf("payload type: %T\n", payload)
if !ok { if !ok {
return errors.New("mqtt.client is only able to output a MQTTMessage") return errors.New("mqtt.client is only able to output a MQTTMessage")
} }
@@ -128,3 +138,7 @@ func (mc *MQTTClient) Output(payload any) error {
return token.Error() return token.Error()
} }
func (mc *MQTTClient) Stop() {
mc.cancel()
}

View File

@@ -19,12 +19,13 @@ type NATSClient struct {
Subject string Subject string
client *nats.Conn client *nats.Conn
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "nats.client", Type: "nats.client",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
url, ok := params["url"] url, ok := params["url"]
@@ -50,7 +51,7 @@ func init() {
return nil, errors.New("nats.client subject must be string") return nil, errors.New("nats.client subject must be string")
} }
return &NATSClient{config: config, URL: urlString, Subject: subjectString, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &NATSClient{config: config, URL: urlString, Subject: subjectString, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -63,7 +64,19 @@ func (nc *NATSClient) Type() string {
return nc.config.Type return nc.config.Type
} }
func (nc *NATSClient) Run() error { func (nc *NATSClient) Start(ctx context.Context) error {
nc.logger.Debug("running")
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("nats.client unable to get router from context")
}
nc.router = router
moduleContext, cancel := context.WithCancel(ctx)
nc.ctx = moduleContext
nc.cancel = cancel
client, err := nats.Connect(nc.URL, nats.RetryOnFailedConnect(true)) client, err := nats.Connect(nc.URL, nats.RetryOnFailedConnect(true))
if err != nil { if err != nil {
@@ -77,7 +90,7 @@ func (nc *NATSClient) Run() error {
sub, err := nc.client.Subscribe(nc.Subject, func(msg *nats.Msg) { sub, err := nc.client.Subscribe(nc.Subject, func(msg *nats.Msg) {
if nc.router != nil { if nc.router != nil {
nc.router.HandleInput(nc.Id(), msg) nc.router.HandleInput(nc.ctx, nc.Id(), msg)
} }
}) })
@@ -88,11 +101,11 @@ func (nc *NATSClient) Run() error {
defer sub.Unsubscribe() defer sub.Unsubscribe()
<-nc.ctx.Done() <-nc.ctx.Done()
nc.logger.Debug("router context done in module") nc.logger.Debug("done")
return nil return nil
} }
func (nc *NATSClient) Output(payload any) error { func (nc *NATSClient) Output(ctx context.Context, payload any) error {
payloadMessage, ok := payload.(processor.NATSMessage) payloadMessage, ok := payload.(processor.NATSMessage)
@@ -112,3 +125,7 @@ func (nc *NATSClient) Output(payload any) error {
return err return err
} }
func (nc *NATSClient) Stop() {
nc.cancel()
}

View File

@@ -2,6 +2,7 @@ package module
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@@ -19,14 +20,15 @@ type PSNClient struct {
router route.RouteIO router route.RouteIO
decoder *psn.Decoder decoder *psn.Decoder
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "psn.client", Type: "psn.client",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
return &PSNClient{config: config, decoder: psn.NewDecoder(), ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &PSNClient{config: config, decoder: psn.NewDecoder(), logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -39,7 +41,17 @@ func (pc *PSNClient) Type() string {
return pc.config.Type return pc.config.Type
} }
func (pc *PSNClient) Run() error { func (pc *PSNClient) Start(ctx context.Context) error {
pc.logger.Debug("running")
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("psn.client unable to get router from context")
}
pc.router = router
moduleContext, cancel := context.WithCancel(ctx)
pc.ctx = moduleContext
pc.cancel = cancel
addr, err := net.ResolveUDPAddr("udp", "236.10.10.10:56565") addr, err := net.ResolveUDPAddr("udp", "236.10.10.10:56565")
if err != nil { if err != nil {
@@ -59,7 +71,7 @@ func (pc *PSNClient) Run() error {
select { select {
case <-pc.ctx.Done(): case <-pc.ctx.Done():
// TODO(jwetzell): cleanup? // TODO(jwetzell): cleanup?
pc.logger.Debug("router context done in module") pc.logger.Debug("done")
return nil return nil
default: default:
pc.conn.SetDeadline(time.Now().Add(time.Millisecond * 200)) pc.conn.SetDeadline(time.Now().Add(time.Millisecond * 200))
@@ -77,21 +89,26 @@ func (pc *PSNClient) Run() error {
message := buffer[:numBytes] message := buffer[:numBytes]
err := pc.decoder.Decode(message) err := pc.decoder.Decode(message)
if err != nil { if err != nil {
pc.logger.Error("psn.client problem decoding psn traffic", "error", err) pc.logger.Error("problem decoding psn traffic", "error", err)
} }
if pc.router != nil { if pc.router != nil {
// TODO(jwetzell): better input handling
for _, tracker := range pc.decoder.Trackers { for _, tracker := range pc.decoder.Trackers {
pc.router.HandleInput(pc.Id(), tracker) pc.router.HandleInput(pc.ctx, pc.Id(), tracker)
} }
} else { } else {
pc.logger.Error("psn.client has no router") pc.logger.Error("has no router")
} }
} }
} }
} }
} }
func (pc *PSNClient) Output(payload any) error { func (pc *PSNClient) Output(ctx context.Context, payload any) error {
return fmt.Errorf("psn.client output is not implemented") return fmt.Errorf("psn.client output is not implemented")
} }
func (pc *PSNClient) Stop() {
pc.cancel()
}

View File

@@ -24,12 +24,13 @@ type SerialClient struct {
Mode *serial.Mode Mode *serial.Mode
port serial.Port port serial.Port
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "serial.client", Type: "serial.client",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
port, ok := params["port"] port, ok := params["port"]
@@ -43,23 +44,22 @@ func init() {
return nil, errors.New("serial.client port must be a string") return nil, errors.New("serial.client port must be a string")
} }
framingMethod := "RAW" framingMethod, ok := params["framing"]
framingMethodRaw, ok := params["framing"] if !ok {
return nil, errors.New("serial.client requires a framing parameter")
}
if ok { framingMethodString, ok := framingMethod.(string)
framingMethodString, ok := framingMethodRaw.(string)
if !ok { if !ok {
return nil, errors.New("serial.client framing method must be a string") return nil, errors.New("serial.client framing method must be a string")
} }
framingMethod = framingMethodString
}
framer, err := framer.GetFramer(framingMethod) framer := framer.GetFramer(framingMethodString)
if err != nil { if framer == nil {
return nil, err return nil, fmt.Errorf("serial.client unknown framing method: %s", framingMethod)
} }
buadRate, ok := params["baudRate"] buadRate, ok := params["baudRate"]
@@ -76,7 +76,7 @@ func init() {
BaudRate: int(baudRateNum), BaudRate: int(baudRateNum),
} }
return &SerialClient{config: config, Port: portString, Framer: framer, Mode: &mode, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &SerialClient{config: config, Port: portString, Framer: framer, Mode: &mode, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -101,12 +101,23 @@ func (sc *SerialClient) SetupPort() error {
return nil return nil
} }
func (sc *SerialClient) Run() error { func (sc *SerialClient) Start(ctx context.Context) error {
sc.logger.Debug("running")
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("serial.client unable to get router from context")
}
sc.router = router
moduleContext, cancel := context.WithCancel(ctx)
sc.ctx = moduleContext
sc.cancel = cancel
// TODO(jwetzell): shutdown with router.Context properly // TODO(jwetzell): shutdown with router.Context properly
go func() { go func() {
<-sc.ctx.Done() <-sc.ctx.Done()
sc.logger.Debug("router context done in module") sc.logger.Debug("done")
if sc.port != nil { if sc.port != nil {
sc.port.Close() sc.port.Close()
} }
@@ -116,10 +127,10 @@ func (sc *SerialClient) Run() error {
err := sc.SetupPort() err := sc.SetupPort()
if err != nil { if err != nil {
if sc.ctx.Err() != nil { if sc.ctx.Err() != nil {
sc.logger.Debug("router context done in module") sc.logger.Debug("done")
return nil return nil
} }
sc.logger.Error("serial.client", "error", err.Error()) sc.logger.Error("port setup error", "error", err.Error())
time.Sleep(time.Second * 2) time.Sleep(time.Second * 2)
continue continue
} }
@@ -127,14 +138,14 @@ func (sc *SerialClient) Run() error {
buffer := make([]byte, 1024) buffer := make([]byte, 1024)
select { select {
case <-sc.ctx.Done(): case <-sc.ctx.Done():
sc.logger.Debug("router context done in module") sc.logger.Debug("done")
return nil return nil
default: default:
READ: READ:
for { for {
select { select {
case <-sc.ctx.Done(): case <-sc.ctx.Done():
sc.logger.Debug("router context done in module") sc.logger.Debug("done")
return nil return nil
default: default:
byteCount, err := sc.port.Read(buffer) byteCount, err := sc.port.Read(buffer)
@@ -149,9 +160,9 @@ func (sc *SerialClient) Run() error {
messages := sc.Framer.Decode(buffer[0:byteCount]) messages := sc.Framer.Decode(buffer[0:byteCount])
for _, message := range messages { for _, message := range messages {
if sc.router != nil { if sc.router != nil {
sc.router.HandleInput(sc.Id(), message) sc.router.HandleInput(sc.ctx, sc.Id(), message)
} else { } else {
sc.logger.Error("serial.client has no router") sc.logger.Error("input received but no router is configured")
} }
} }
} }
@@ -162,7 +173,7 @@ func (sc *SerialClient) Run() error {
} }
} }
func (sc *SerialClient) Output(payload any) error { func (sc *SerialClient) Output(ctx context.Context, payload any) error {
payloadBytes, ok := payload.([]byte) payloadBytes, ok := payload.([]byte)
@@ -173,3 +184,7 @@ func (sc *SerialClient) Output(payload any) error {
_, err := sc.port.Write(sc.Framer.Encode(payloadBytes)) _, err := sc.port.Write(sc.Framer.Encode(payloadBytes))
return err return err
} }
func (sc *SerialClient) Stop() {
sc.cancel()
}

View File

@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"os"
"sync"
"time" "time"
"github.com/emiago/diago" "github.com/emiago/diago"
@@ -13,6 +15,7 @@ import (
"github.com/emiago/sipgo" "github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip" "github.com/emiago/sipgo/sip"
"github.com/jwetzell/showbridge-go/internal/config" "github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
"github.com/jwetzell/showbridge-go/internal/route" "github.com/jwetzell/showbridge-go/internal/route"
) )
@@ -26,16 +29,24 @@ type SIPCallServer struct {
UserAgent string UserAgent string
dg *diago.Diago dg *diago.Diago
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
type SIPCallMessage struct { type SIPCallMessage struct {
To string To string
} }
type SIPCall struct {
inDialog *diago.DialogServerSession
lock sync.Mutex
}
type sipCallContextKey string
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "sip.call.server", Type: "sip.call.server",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
portNum := 5060 portNum := 5060
@@ -87,7 +98,8 @@ func init() {
} }
userAgentString = specificTransportString userAgentString = specificTransportString
} }
return &SIPCallServer{config: config, ctx: ctx, router: router, IP: ipString, Port: int(portNum), Transport: transportString, UserAgent: userAgentString, logger: slog.Default().With("component", "module", "id", config.Id)}, nil
return &SIPCallServer{config: config, IP: ipString, Port: int(portNum), Transport: transportString, UserAgent: userAgentString, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -100,7 +112,18 @@ func (scs *SIPCallServer) Type() string {
return scs.config.Type return scs.config.Type
} }
func (scs *SIPCallServer) Run() error { func (scs *SIPCallServer) Start(ctx context.Context) error {
scs.logger.Debug("running")
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
moduleContext, cancel := context.WithCancel(ctx)
scs.ctx = moduleContext
scs.cancel = cancel
diagoLogger := slog.New(slog.NewJSONHandler(io.Discard, nil)) diagoLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
ua, _ := sipgo.NewUA( ua, _ := sipgo.NewUA(
@@ -129,7 +152,7 @@ func (scs *SIPCallServer) Run() error {
scs.dg = dg scs.dg = dg
<-scs.ctx.Done() <-scs.ctx.Done()
scs.logger.Debug("router context done in module") scs.logger.Debug("done")
return nil return nil
} }
@@ -137,51 +160,79 @@ func (scs *SIPCallServer) HandleCall(inDialog *diago.DialogServerSession) {
inDialog.Trying() inDialog.Trying()
inDialog.Ringing() inDialog.Ringing()
inDialog.Answer() inDialog.Answer()
scs.router.HandleInput(scs.Id(), SIPCallMessage{
dialogContext := context.WithValue(scs.ctx, sipCallContextKey("call"), &SIPCall{
inDialog: inDialog,
})
scs.router.HandleInput(dialogContext, scs.Id(), SIPCallMessage{
To: inDialog.ToUser(), To: inDialog.ToUser(),
}) })
<-inDialog.Context().Done()
} }
func (scs *SIPCallServer) Output(payload any) error { func (scs *SIPCallServer) Output(ctx context.Context, payload any) error {
call, ok := ctx.Value(sipCallContextKey("call")).(*SIPCall)
payloadMsg, ok := payload.(string)
if !ok { if !ok {
return errors.New("sip.call.server output payload must be of type string") return errors.New("sip.call.server output must originate from sip.call.server input")
} }
if scs.dg == nil { gotLock := call.lock.TryLock()
return errors.New("sip.call.server diago is not initialized")
if !gotLock {
return errors.New("sip.call.server call is already locked")
} }
var uri sip.Uri if call.inDialog.LoadState() == sip.DialogStateEnded {
err := sip.ParseUri(payloadMsg, &uri) return errors.New("sip.call.server inDialog already ended")
if err != nil {
return fmt.Errorf("sip.call.server output payload is not a valid SIP URI: %s", err)
}
outDialog, err := scs.dg.NewDialog(uri, diago.NewDialogOptions{
Transport: scs.Transport,
})
if err != nil {
return fmt.Errorf("sip.call.server failed to create new dialog: %s", err)
} }
err = outDialog.Invite(scs.ctx, diago.InviteClientOptions{}) 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 { if err != nil {
return fmt.Errorf("sip.call.server failed to send invite: %s", err) return fmt.Errorf("sip.dtmf.server error output dtmf digit at index %d", i)
}
}
time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait))
return nil
} }
err = outDialog.Ack(scs.ctx) payloadAudioFileResponse, ok := payload.(processor.SipAudioFileResponse)
if ok {
audioFile, err := os.Open(payloadAudioFileResponse.AudioFile)
if err != nil { if err != nil {
return fmt.Errorf("sip.call.server failed to send ack: %s", err) return err
} }
// TODO(jwetzell): make this configurable defer audioFile.Close()
// NOTE(jwetzell): wait 5 seconds before hanging up the call
time.Sleep(5 * time.Second) playback, err := call.inDialog.PlaybackCreate()
err = outDialog.Hangup(scs.ctx)
if err != nil { if err != nil {
return fmt.Errorf("sip.call.server failed to hangup call: %s", err) 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 nil
} }
return errors.New("sip.dtmf.server can only output SipDTMFResponse or SipAudioFileResponse")
}
func (scs *SIPCallServer) Stop() {
scs.cancel()
}

View File

@@ -3,9 +3,12 @@ package module
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"log/slog" "log/slog"
"os"
"strings" "strings"
"sync"
"time" "time"
"github.com/emiago/diago" "github.com/emiago/diago"
@@ -13,6 +16,7 @@ import (
"github.com/emiago/sipgo" "github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip" "github.com/emiago/sipgo/sip"
"github.com/jwetzell/showbridge-go/internal/config" "github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
"github.com/jwetzell/showbridge-go/internal/route" "github.com/jwetzell/showbridge-go/internal/route"
) )
@@ -25,6 +29,7 @@ type SIPDTMFServer struct {
Transport string Transport string
Separator string Separator string
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
type SIPDTMFMessage struct { type SIPDTMFMessage struct {
@@ -32,10 +37,15 @@ type SIPDTMFMessage struct {
Digits string Digits string
} }
type SIPDTMFCall struct {
inDialog *diago.DialogServerSession
lock sync.Mutex
}
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "sip.dtmf.server", Type: "sip.dtmf.server",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
portNum := 5060 portNum := 5060
@@ -91,7 +101,7 @@ func init() {
if !strings.ContainsRune("0123456789*#ABCD", rune(separatorString[0])) { if !strings.ContainsRune("0123456789*#ABCD", rune(separatorString[0])) {
return nil, errors.New("sip.dtmf.server separator must be a valid DTMF character") return nil, errors.New("sip.dtmf.server separator must be a valid DTMF character")
} }
return &SIPDTMFServer{config: config, ctx: ctx, router: router, IP: ipString, Port: int(portNum), Transport: transportString, Separator: separatorString, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &SIPDTMFServer{config: config, IP: ipString, Port: int(portNum), Transport: transportString, Separator: separatorString, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -104,7 +114,18 @@ func (sds *SIPDTMFServer) Type() string {
return sds.config.Type return sds.config.Type
} }
func (sds *SIPDTMFServer) Run() error { func (sds *SIPDTMFServer) Start(ctx context.Context) error {
sds.logger.Debug("running")
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
moduleContext, cancel := context.WithCancel(ctx)
sds.ctx = moduleContext
sds.cancel = cancel
diagoLogger := slog.New(slog.NewJSONHandler(io.Discard, nil)) diagoLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
ua, _ := sipgo.NewUA( ua, _ := sipgo.NewUA(
@@ -132,7 +153,7 @@ func (sds *SIPDTMFServer) Run() error {
} }
<-sds.ctx.Done() <-sds.ctx.Done()
sds.logger.Debug("router context done in module") sds.logger.Debug("done")
return nil return nil
} }
@@ -143,10 +164,14 @@ func (sds *SIPDTMFServer) HandleCall(inDialog *diago.DialogServerSession) error
reader := inDialog.AudioReaderDTMF() reader := inDialog.AudioReaderDTMF()
userString := "" userString := ""
return reader.Listen(func(dtmf rune) error { return reader.Listen(func(dtmf rune) error {
if dtmf == rune(sds.Separator[0]) { if dtmf == rune(sds.Separator[0]) {
if sds.router != nil { if sds.router != nil {
sds.router.HandleInput(sds.Id(), SIPDTMFMessage{ dialogContext := context.WithValue(sds.ctx, sipCallContextKey("call"), &SIPDTMFCall{
inDialog: inDialog,
})
sds.router.HandleInput(dialogContext, sds.Id(), SIPDTMFMessage{
To: inDialog.ToUser(), To: inDialog.ToUser(),
Digits: userString, Digits: userString,
}) })
@@ -159,6 +184,70 @@ func (sds *SIPDTMFServer) HandleCall(inDialog *diago.DialogServerSession) error
}, 5*time.Second) }, 5*time.Second)
} }
func (sds *SIPDTMFServer) Output(payload any) error { func (sds *SIPDTMFServer) Output(ctx context.Context, payload any) error {
return errors.New("sip.dtmf.server output is not implemented") 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")
}
func (sds *SIPDTMFServer) Stop() {
sds.cancel()
} }

View File

@@ -21,12 +21,13 @@ type TCPClient struct {
router route.RouteIO router route.RouteIO
Addr *net.TCPAddr Addr *net.TCPAddr
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "net.tcp.client", Type: "net.tcp.client",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
host, ok := params["host"] host, ok := params["host"]
@@ -56,26 +57,24 @@ func init() {
return nil, err return nil, err
} }
framingMethod := "RAW" framingMethod, ok := params["framing"]
framingMethodRaw, ok := params["framing"]
if ok {
framingMethodString, ok := framingMethodRaw.(string)
if !ok { if !ok {
return nil, errors.New("misc.serial.client framing method must be a string") return nil, errors.New("net.tcp.client requires a framing parameter")
}
framingMethod = framingMethodString
} }
framer, err := framer.GetFramer(framingMethod) framingMethodString, ok := framingMethod.(string)
if err != nil { if !ok {
return nil, err return nil, errors.New("net.tcp.client framing method must be a string")
} }
return &TCPClient{framer: framer, Addr: addr, config: config, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil framer := framer.GetFramer(framingMethodString)
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
}, },
}) })
} }
@@ -88,12 +87,22 @@ func (tc *TCPClient) Type() string {
return tc.config.Type return tc.config.Type
} }
func (tc *TCPClient) Run() error { func (tc *TCPClient) Start(ctx context.Context) error {
tc.logger.Debug("running")
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
moduleContext, cancel := context.WithCancel(ctx)
tc.ctx = moduleContext
tc.cancel = cancel
// TODO(jwetzell): shutdown with router.Context properly // TODO(jwetzell): shutdown with router.Context properly
go func() { go func() {
<-tc.ctx.Done() <-tc.ctx.Done()
tc.logger.Debug("router context done in module") tc.logger.Debug("done")
if tc.conn != nil { if tc.conn != nil {
tc.conn.Close() tc.conn.Close()
} }
@@ -103,10 +112,10 @@ func (tc *TCPClient) Run() error {
err := tc.SetupConn() err := tc.SetupConn()
if err != nil { if err != nil {
if tc.ctx.Err() != nil { if tc.ctx.Err() != nil {
tc.logger.Debug("router context done in module") tc.logger.Debug("done")
return nil return nil
} }
tc.logger.Error("net.tcp.client", "error", err.Error()) tc.logger.Error("connection error", "error", err.Error())
time.Sleep(time.Second * 2) time.Sleep(time.Second * 2)
continue continue
} }
@@ -114,14 +123,14 @@ func (tc *TCPClient) Run() error {
buffer := make([]byte, 1024) buffer := make([]byte, 1024)
select { select {
case <-tc.ctx.Done(): case <-tc.ctx.Done():
tc.logger.Debug("router context done in module") tc.logger.Debug("done")
return nil return nil
default: default:
READ: READ:
for { for {
select { select {
case <-tc.ctx.Done(): case <-tc.ctx.Done():
tc.logger.Debug("router context done in module") tc.logger.Debug("done")
return nil return nil
default: default:
byteCount, err := tc.conn.Read(buffer) byteCount, err := tc.conn.Read(buffer)
@@ -136,9 +145,9 @@ func (tc *TCPClient) Run() error {
messages := tc.framer.Decode(buffer[0:byteCount]) messages := tc.framer.Decode(buffer[0:byteCount])
for _, message := range messages { for _, message := range messages {
if tc.router != nil { if tc.router != nil {
tc.router.HandleInput(tc.Id(), message) tc.router.HandleInput(tc.ctx, tc.Id(), message)
} else { } else {
tc.logger.Error("net.tcp.client has no router") tc.logger.Error("input received but no router is configured")
} }
} }
} }
@@ -155,7 +164,7 @@ func (tc *TCPClient) SetupConn() error {
return err return err
} }
func (tc *TCPClient) Output(payload any) error { func (tc *TCPClient) Output(ctx context.Context, payload any) error {
// NOTE(jwetzell): not sure how this would occur but // NOTE(jwetzell): not sure how this would occur but
if tc.conn == nil { if tc.conn == nil {
err := tc.SetupConn() err := tc.SetupConn()
@@ -170,3 +179,7 @@ func (tc *TCPClient) Output(payload any) error {
_, err := tc.conn.Write(tc.framer.Encode(payloadBytes)) _, err := tc.conn.Write(tc.framer.Encode(payloadBytes))
return err return err
} }
func (tc *TCPClient) Stop() {
tc.cancel()
}

View File

@@ -27,12 +27,13 @@ type TCPServer struct {
connections []*net.TCPConn connections []*net.TCPConn
connectionsMu sync.RWMutex connectionsMu sync.RWMutex
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "net.tcp.server", Type: "net.tcp.server",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
port, ok := params["port"] port, ok := params["port"]
if !ok { if !ok {
@@ -45,23 +46,22 @@ func init() {
return nil, errors.New("net.tcp.server port must be a number") return nil, errors.New("net.tcp.server port must be a number")
} }
framingMethod := "RAW" framingMethod, ok := params["framing"]
framingMethodRaw, ok := params["framing"]
if ok {
framingMethodString, ok := framingMethodRaw.(string)
if !ok { if !ok {
return nil, errors.New("misc.serial.client framing method must be a string") return nil, errors.New("net.tcp.server requires a framing parameter")
}
framingMethod = framingMethodString
} }
framer, err := framer.GetFramer(framingMethod) framingMethodString, ok := framingMethod.(string)
if err != nil { if !ok {
return nil, err return nil, errors.New("net.tcp.server framing method must be a string")
}
framer := framer.GetFramer(framingMethodString)
if framer == nil {
return nil, fmt.Errorf("net.tcp.server unknown framing method: %s", framingMethod)
} }
ipString := "0.0.0.0" ipString := "0.0.0.0"
@@ -81,8 +81,7 @@ func init() {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &TCPServer{Framer: framer, Addr: addr, config: config, quit: make(chan interface{}), logger: CreateLogger(config)}, nil
return &TCPServer{Framer: framer, Addr: addr, config: config, quit: make(chan interface{}), ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil
}, },
}) })
} }
@@ -107,6 +106,15 @@ ClientRead:
for { for {
select { select {
case <-ts.quit: case <-ts.quit:
client.Close()
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.connectionsMu.Unlock()
return return
default: default:
client.SetDeadline(time.Now().Add(time.Millisecond * 200)) client.SetDeadline(time.Now().Add(time.Millisecond * 200))
@@ -126,7 +134,7 @@ ClientRead:
break break
} }
} }
ts.logger.Debug("net.tcp.server connection reset", "remoteAddr", client.RemoteAddr().String()) ts.logger.Debug("connection reset", "remoteAddr", client.RemoteAddr().String())
ts.connectionsMu.Unlock() ts.connectionsMu.Unlock()
} }
} }
@@ -139,7 +147,7 @@ ClientRead:
break break
} }
} }
ts.logger.Debug("net.tcp.server stream ended", "remoteAddr", client.RemoteAddr().String()) ts.logger.Debug("stream ended", "remoteAddr", client.RemoteAddr().String())
ts.connectionsMu.Unlock() ts.connectionsMu.Unlock()
} }
return return
@@ -149,9 +157,9 @@ ClientRead:
messages := ts.Framer.Decode(buffer[0:byteCount]) messages := ts.Framer.Decode(buffer[0:byteCount])
for _, message := range messages { for _, message := range messages {
if ts.router != nil { if ts.router != nil {
ts.router.HandleInput(ts.Id(), message) ts.router.HandleInput(ts.ctx, ts.Id(), message)
} else { } else {
ts.logger.Error("net.tcp.server has no router") ts.logger.Error("input received but no router is configured")
} }
} }
} }
@@ -160,7 +168,18 @@ ClientRead:
} }
} }
func (ts *TCPServer) Run() error { func (ts *TCPServer) Start(ctx context.Context) error {
ts.logger.Debug("running")
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
moduleContext, cancel := context.WithCancel(ctx)
ts.ctx = moduleContext
ts.cancel = cancel
listener, err := net.ListenTCP("tcp", ts.Addr) listener, err := net.ListenTCP("tcp", ts.Addr)
if err != nil { if err != nil {
return err return err
@@ -171,7 +190,7 @@ func (ts *TCPServer) Run() error {
<-ts.ctx.Done() <-ts.ctx.Done()
close(ts.quit) close(ts.quit)
listener.Close() listener.Close()
ts.logger.Debug("router context done in module") ts.logger.Debug("done")
}() }()
AcceptLoop: AcceptLoop:
@@ -182,7 +201,7 @@ AcceptLoop:
case <-ts.quit: case <-ts.quit:
break AcceptLoop break AcceptLoop
default: default:
ts.logger.Debug("net.tcp.server problem with listener", "error", err) ts.logger.Debug("problem with listener", "error", err)
} }
} else { } else {
ts.wg.Add(1) ts.wg.Add(1)
@@ -197,7 +216,7 @@ AcceptLoop:
return nil return nil
} }
func (ts *TCPServer) Output(payload any) error { func (ts *TCPServer) Output(ctx context.Context, payload any) error {
payloadBytes, ok := payload.([]byte) payloadBytes, ok := payload.([]byte)
if !ok { if !ok {
@@ -219,3 +238,8 @@ func (ts *TCPServer) Output(payload any) error {
} }
return fmt.Errorf("net.tcp.server error during output: %s", errorString) return fmt.Errorf("net.tcp.server error during output: %s", errorString)
} }
func (ts *TCPServer) Stop() {
ts.cancel()
ts.wg.Wait()
}

View File

@@ -0,0 +1,89 @@
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
cancel context.CancelFunc
}
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) Start(ctx context.Context) error {
i.logger.Debug("running")
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return errors.New("time.interval unable to get router from context")
}
i.router = router
moduleContext, cancel := context.WithCancel(ctx)
i.ctx = moduleContext
i.cancel = cancel
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
}
func (i *TimeInterval) Stop() {
i.cancel()
}

View File

@@ -0,0 +1,88 @@
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
cancel context.CancelFunc
}
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) Start(ctx context.Context) error {
t.logger.Debug("running")
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
moduleContext, cancel := context.WithCancel(ctx)
t.ctx = moduleContext
t.cancel = cancel
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
}
func (t *TimeTimer) Stop() {
t.cancel()
}

View File

@@ -1,72 +0,0 @@
package module
import (
"context"
"errors"
"log/slog"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
type Timer struct {
config config.ModuleConfig
Duration uint32
ctx context.Context
router route.RouteIO
timer *time.Timer
logger *slog.Logger
}
func init() {
RegisterModule(ModuleRegistration{
Type: "gen.timer",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) {
params := config.Params
duration, ok := params["duration"]
if !ok {
return nil, errors.New("gen.timer requires a duration parameter")
}
durationNum, ok := duration.(float64)
if !ok {
return nil, errors.New("gen.timer duration must be a number")
}
return &Timer{Duration: uint32(durationNum), config: config, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil
},
})
}
func (t *Timer) Id() string {
return t.config.Id
}
func (t *Timer) Type() string {
return t.config.Type
}
func (t *Timer) Run() error {
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("router context done in module")
return nil
case time := <-t.timer.C:
if t.router != nil {
t.router.HandleInput(t.Id(), time)
}
}
}
}
func (t *Timer) Output(payload any) error {
t.timer.Reset(time.Millisecond * time.Duration(t.Duration))
return nil
}

View File

@@ -19,12 +19,13 @@ type UDPClient struct {
ctx context.Context ctx context.Context
router route.RouteIO router route.RouteIO
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "net.udp.client", Type: "net.udp.client",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
host, ok := params["host"] host, ok := params["host"]
@@ -53,8 +54,7 @@ func init() {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &UDPClient{Addr: addr, config: config, logger: CreateLogger(config)}, nil
return &UDPClient{Addr: addr, config: config, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil
}, },
}) })
} }
@@ -73,7 +73,17 @@ func (uc *UDPClient) SetupConn() error {
return err return err
} }
func (uc *UDPClient) Run() error { func (uc *UDPClient) Start(ctx context.Context) error {
uc.logger.Debug("running")
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
moduleContext, cancel := context.WithCancel(ctx)
uc.ctx = moduleContext
uc.cancel = cancel
err := uc.SetupConn() err := uc.SetupConn()
if err != nil { if err != nil {
@@ -81,14 +91,14 @@ func (uc *UDPClient) Run() error {
} }
<-uc.ctx.Done() <-uc.ctx.Done()
uc.logger.Debug("router context done in module") uc.logger.Debug("done")
if uc.conn != nil { if uc.conn != nil {
uc.conn.Close() uc.conn.Close()
} }
return nil return nil
} }
func (uc *UDPClient) Output(payload any) error { func (uc *UDPClient) Output(ctx context.Context, payload any) error {
payloadBytes, ok := payload.([]byte) payloadBytes, ok := payload.([]byte)
if !ok { if !ok {
@@ -105,3 +115,7 @@ func (uc *UDPClient) Output(payload any) error {
} }
return nil return nil
} }
func (uc *UDPClient) Stop() {
uc.cancel()
}

View File

@@ -19,12 +19,13 @@ type UDPMulticast struct {
router route.RouteIO router route.RouteIO
Addr *net.UDPAddr Addr *net.UDPAddr
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "net.udp.multicast", Type: "net.udp.multicast",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
ip, ok := params["ip"] ip, ok := params["ip"]
@@ -53,7 +54,7 @@ func init() {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &UDPMulticast{config: config, Addr: addr, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil return &UDPMulticast{config: config, Addr: addr, logger: CreateLogger(config)}, nil
}, },
}) })
} }
@@ -66,7 +67,17 @@ func (um *UDPMulticast) Type() string {
return um.config.Type return um.config.Type
} }
func (um *UDPMulticast) Run() error { func (um *UDPMulticast) Start(ctx context.Context) error {
um.logger.Debug("running")
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
moduleContext, cancel := context.WithCancel(ctx)
um.ctx = moduleContext
um.cancel = cancel
client, err := net.ListenMulticastUDP("udp", nil, um.Addr) client, err := net.ListenMulticastUDP("udp", nil, um.Addr)
if err != nil { if err != nil {
@@ -81,7 +92,7 @@ func (um *UDPMulticast) Run() error {
select { select {
case <-um.ctx.Done(): case <-um.ctx.Done():
// TODO(jwetzell): cleanup? // TODO(jwetzell): cleanup?
um.logger.Debug("router context done in module") um.logger.Debug("done")
return nil return nil
default: default:
um.conn.SetDeadline(time.Now().Add(time.Millisecond * 200)) um.conn.SetDeadline(time.Now().Add(time.Millisecond * 200))
@@ -99,16 +110,16 @@ func (um *UDPMulticast) Run() error {
message := buffer[:numBytes] message := buffer[:numBytes]
if um.router != nil { if um.router != nil {
um.router.HandleInput(um.Id(), message) um.router.HandleInput(um.ctx, um.Id(), message)
} else { } else {
um.logger.Error("net.udp.multicast has no router") um.logger.Error("input received but no router is configured")
} }
} }
} }
} }
} }
func (um *UDPMulticast) Output(payload any) error { func (um *UDPMulticast) Output(ctx context.Context, payload any) error {
payloadBytes, ok := payload.([]byte) payloadBytes, ok := payload.([]byte)
if !ok { if !ok {
@@ -122,3 +133,7 @@ func (um *UDPMulticast) Output(payload any) error {
_, err := um.conn.Write(payloadBytes) _, err := um.conn.Write(payloadBytes)
return err return err
} }
func (um *UDPMulticast) Stop() {
um.cancel()
}

View File

@@ -20,12 +20,13 @@ type UDPServer struct {
ctx context.Context ctx context.Context
router route.RouteIO router route.RouteIO
logger *slog.Logger logger *slog.Logger
cancel context.CancelFunc
} }
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "net.udp.server", Type: "net.udp.server",
New: func(ctx context.Context, config config.ModuleConfig, router route.RouteIO) (Module, error) { New: func(config config.ModuleConfig) (Module, error) {
params := config.Params params := config.Params
port, ok := params["port"] port, ok := params["port"]
if !ok { if !ok {
@@ -67,8 +68,7 @@ func init() {
} }
bufferSizeNum = int(bufferSizeFloat) bufferSizeNum = int(bufferSizeFloat)
} }
return &UDPServer{Addr: addr, BufferSize: bufferSizeNum, config: config, logger: CreateLogger(config)}, nil
return &UDPServer{Addr: addr, BufferSize: bufferSizeNum, config: config, ctx: ctx, router: router, logger: slog.Default().With("component", "module", "id", config.Id)}, nil
}, },
}) })
} }
@@ -81,7 +81,17 @@ func (us *UDPServer) Type() string {
return us.config.Id return us.config.Id
} }
func (us *UDPServer) Run() error { func (us *UDPServer) Start(ctx context.Context) error {
us.logger.Debug("running")
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
moduleContext, cancel := context.WithCancel(ctx)
us.ctx = moduleContext
us.cancel = cancel
listener, err := net.ListenUDP("udp", us.Addr) listener, err := net.ListenUDP("udp", us.Addr)
if err != nil { if err != nil {
@@ -95,7 +105,7 @@ func (us *UDPServer) Run() error {
select { select {
case <-us.ctx.Done(): case <-us.ctx.Done():
// TODO(jwetzell): cleanup? // TODO(jwetzell): cleanup?
us.logger.Debug("router context done in module") us.logger.Debug("done")
return nil return nil
default: default:
listener.SetDeadline(time.Now().Add(time.Millisecond * 200)) listener.SetDeadline(time.Now().Add(time.Millisecond * 200))
@@ -110,15 +120,19 @@ func (us *UDPServer) Run() error {
} }
message := buffer[:numBytes] message := buffer[:numBytes]
if us.router != nil { if us.router != nil {
us.router.HandleInput(us.Id(), message) us.router.HandleInput(us.ctx, us.Id(), message)
} else { } else {
us.logger.Error("net.udp.server has no router") us.logger.Error("input received but no router is configured")
} }
} }
} }
} }
func (us *UDPServer) Output(payload any) error { func (us *UDPServer) Output(ctx context.Context, payload any) error {
return errors.New("net.udp.server output is not implemented") return errors.New("net.udp.server output is not implemented")
} }
func (us *UDPServer) Stop() {
us.cancel()
}

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,239 @@
package processor_test
import (
"reflect"
"testing"
"github.com/jwetzell/artnet-go"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestArtnetPacketFilterFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["artnet.packet.filter"]
if !ok {
t.Fatalf("artnet.packet.filter processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "artnet.packet.filter",
Params: map[string]any{
"opCode": float64(artnet.OpTimeCode),
},
})
if err != nil {
t.Fatalf("failed to create artnet.packet.filter processor: %s", err)
}
if processorInstance.Type() != "artnet.packet.filter" {
t.Fatalf("artnet.packet.filter processor has wrong type: %s", processorInstance.Type())
}
payload := &artnet.ArtTimeCode{
ID: []byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00},
OpCode: artnet.OpTimeCode,
ProtVerHi: 0,
ProtVerLo: 14,
Filler1: 0,
StreamId: 0,
Frames: 11,
Seconds: 17,
Minutes: 3,
Hours: 0,
Type: 0,
}
expected := &artnet.ArtTimeCode{
ID: []byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00},
OpCode: artnet.OpTimeCode,
ProtVerHi: 0,
ProtVerLo: 14,
Filler1: 0,
StreamId: 0,
Frames: 11,
Seconds: 17,
Minutes: 3,
Hours: 0,
Type: 0,
}
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("artnet.packet.filter processing failed: %s", err)
}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("artnet.packet.filter got %+v, expected %+v", got, expected)
}
}
func TestGoodArtnetPacketFilter(t *testing.T) {
tests := []struct {
name string
payload any
opCode uint16
expected artnet.ArtNetPacket
}{
{
name: "tiemcode packet with matching opCode",
payload: &artnet.ArtTimeCode{
ID: []byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00},
OpCode: artnet.OpTimeCode,
ProtVerHi: 0,
ProtVerLo: 14,
Filler1: 0,
StreamId: 0,
Frames: 11,
Seconds: 17,
Minutes: 3,
Hours: 0,
Type: 0,
},
opCode: artnet.OpTimeCode,
expected: &artnet.ArtTimeCode{
ID: []byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00},
OpCode: artnet.OpTimeCode,
ProtVerHi: 0,
ProtVerLo: 14,
Filler1: 0,
StreamId: 0,
Frames: 11,
Seconds: 17,
Minutes: 3,
Hours: 0,
Type: 0,
},
},
{
name: "tiemcode packet with mismatching opCode",
payload: &artnet.ArtTimeCode{
ID: []byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00},
OpCode: artnet.OpTimeCode,
ProtVerHi: 0,
ProtVerLo: 14,
Filler1: 0,
StreamId: 0,
Frames: 11,
Seconds: 17,
Minutes: 3,
Hours: 0,
Type: 0,
},
opCode: artnet.OpDmx,
expected: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
artnetPacketFilter := processor.ArtNetPacketFilter{
OpCode: test.opCode,
}
got, err := artnetPacketFilter.Process(t.Context(), test.payload)
if err != nil {
t.Fatalf("artnet.packet.filter failed: %s", err)
}
if test.expected == nil {
if got != nil {
t.Fatalf("artnet.packet.filter got %+v, expected nil", got)
}
return
}
gotPacket, ok := got.(artnet.ArtNetPacket)
if !ok {
t.Fatalf("artnet.packet.filter returned a %T payload: %s", got, got)
}
if !reflect.DeepEqual(gotPacket, test.expected) {
t.Fatalf("artnet.packet.filter got %+v, expected %+v", gotPacket, test.expected)
}
})
}
}
func TestBadArtnetPacketFilter(t *testing.T) {
tests := []struct {
name string
payload any
params map[string]any
errorString string
}{
{
name: "non-artnet input",
payload: []byte{0x01},
params: map[string]any{"opCode": float64(artnet.OpTimeCode)},
errorString: "artnet.packet.filter processor only accepts an ArtNetPacket",
},
{
name: "no opCode param",
payload: &artnet.ArtTimeCode{
ID: []byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00},
OpCode: artnet.OpTimeCode,
ProtVerHi: 0,
ProtVerLo: 14,
Filler1: 0,
StreamId: 0,
Frames: 11,
Seconds: 17,
Minutes: 3,
Hours: 0,
Type: 0,
},
params: map[string]any{},
errorString: "artnet.packet.filter requires an opCode parameter",
},
{
name: "opCode not a number",
payload: &artnet.ArtTimeCode{
ID: []byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00},
OpCode: artnet.OpTimeCode,
ProtVerHi: 0,
ProtVerLo: 14,
Filler1: 0,
StreamId: 0,
Frames: 11,
Seconds: 17,
Minutes: 3,
Hours: 0,
Type: 0,
},
params: map[string]any{"opCode": "100"},
errorString: "artnet.packet.filter opCode must be a number",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registration, ok := processor.ProcessorRegistry["artnet.packet.filter"]
if !ok {
t.Fatalf("artnet.packet.filter processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "artnet.packet.filter",
Params: test.params,
})
if err != nil {
if test.errorString != err.Error() {
t.Fatalf("artnet.packet.filter got error '%s', expected '%s'", err.Error(), test.errorString)
}
return
}
got, err := processorInstance.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("artnet.packet.filter expected to fail but got payload: %s", got)
}
if err.Error() != test.errorString {
t.Fatalf("artnet.packet.filter got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -14,7 +14,9 @@ type DebugLog struct {
} }
func (dl *DebugLog) Process(ctx context.Context, payload any) (any, error) { func (dl *DebugLog) Process(ctx context.Context, payload any) (any, error) {
dl.logger.Debug("debug.log", "payload", payload, "payloadType", fmt.Sprintf("%T", payload)) payloadString := fmt.Sprintf("%+v", payload)
payloadType := fmt.Sprintf("%T", payload)
dl.logger.Debug("", "payload", payloadString, "payloadType", payloadType)
return payload, nil return payload, nil
} }
@@ -26,7 +28,7 @@ func init() {
RegisterProcessor(ProcessorRegistration{ RegisterProcessor(ProcessorRegistration{
Type: "debug.log", Type: "debug.log",
New: func(config config.ProcessorConfig) (Processor, error) { New: func(config config.ProcessorConfig) (Processor, error) {
return &DebugLog{config: config, logger: slog.Default().With("component", "processor")}, nil 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

@@ -3,30 +3,107 @@ package processor_test
import ( import (
"testing" "testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor" "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) { func TestGoodFloatParse(t *testing.T) {
floatParser := processor.FloatParse{} floatParser := processor.FloatParse{}
tests := []struct { tests := []struct {
processor processor.Processor processor processor.Processor
name string name string
payload any payload any
bitSize int
expected float64 expected float64
}{ }{
{ {
name: "positive number", name: "positive number",
payload: "12345.67", payload: "12345.67",
bitSize: 64,
expected: 12345.67, expected: 12345.67,
}, },
{ {
name: "negative number", name: "negative number",
payload: "-12345.67", payload: "-12345.67",
bitSize: 64,
expected: -12345.67, expected: -12345.67,
}, },
{ {
name: "zero", name: "zero",
payload: "0", payload: "0",
bitSize: 64,
expected: 0, expected: 0,
}, },
} }
@@ -37,48 +114,59 @@ func TestGoodFloatParse(t *testing.T) {
gotFloat, ok := got.(float64) gotFloat, ok := got.(float64)
if !ok { if !ok {
t.Errorf("float.parse returned a %T payload: %s", got, got) t.Fatalf("float.parse returned a %T payload: %s", got, got)
} }
if err != nil { if err != nil {
t.Errorf("float.parse failed: %s", err) t.Fatalf("float.parse failed: %s", err)
} }
if gotFloat != test.expected { if gotFloat != test.expected {
t.Errorf("float.parse got %f, expected %f", gotFloat, test.expected) t.Fatalf("float.parse got %f, expected %f", gotFloat, test.expected)
} }
}) })
} }
} }
func TestBadFloatParse(t *testing.T) { func TestBadFloatParse(t *testing.T) {
floatParser := processor.FloatParse{}
tests := []struct { tests := []struct {
processor processor.Processor processor processor.Processor
name string name string
payload any payload any
bitSize int
errorString string errorString string
}{ }{
{ {
name: "non-string input", name: "non-string input",
payload: []byte{0x01}, payload: []byte{0x01},
bitSize: 64,
errorString: "float.parse processor only accepts a string", errorString: "float.parse processor only accepts a string",
}, },
{ {
name: "not float string", name: "not float string",
payload: "abcd", payload: "abcd",
bitSize: 64,
errorString: "strconv.ParseFloat: parsing \"abcd\": invalid syntax", 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
floatParser := processor.FloatParse{
BitSize: test.bitSize,
}
got, err := floatParser.Process(t.Context(), test.payload) got, err := floatParser.Process(t.Context(), test.payload)
if err == nil { if err == nil {
t.Errorf("float.parse expected to fail but succeeded, got: %v", got) t.Fatalf("float.parse expected to fail but succeeded, got: %v", got)
} }
if err.Error() != test.errorString { if err.Error() != test.errorString {
t.Errorf("float.parse got error '%s', expected '%s'", err.Error(), test.errorString) t.Fatalf("float.parse got error '%s', expected '%s'", err.Error(), test.errorString)
} }
}) })
} }

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

@@ -3,82 +3,216 @@ package processor_test
import ( import (
"testing" "testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor" "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) { func TestGoodIntParse(t *testing.T) {
intParser := processor.IntParse{}
tests := []struct { tests := []struct {
processor processor.Processor processor processor.Processor
name string name string
payload any payload any
expected int64 expected int64
base int
bitSize int
}{ }{
{ {
name: "positive number", name: "positive number",
payload: "12345", payload: "12345",
expected: 12345, expected: 12345,
base: 10,
bitSize: 64,
}, },
{ {
name: "negative number", name: "negative number",
payload: "-12345", payload: "-12345",
expected: -12345, expected: -12345,
base: 10,
bitSize: 64,
}, },
{ {
name: "zero", name: "zero",
payload: "0", payload: "0",
expected: 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { 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) got, err := intParser.Process(t.Context(), test.payload)
gotInt, ok := got.(int64) gotInt, ok := got.(int64)
if !ok { if !ok {
t.Errorf("int.parse returned a %T payload: %s", got, got) t.Fatalf("int.parse returned a %T payload: %s", got, got)
} }
if err != nil { if err != nil {
t.Errorf("int.parse failed: %s", err) t.Fatalf("int.parse failed: %s", err)
} }
if gotInt != test.expected { if gotInt != test.expected {
t.Errorf("int.parse got %d, expected %d", gotInt, test.expected) t.Fatalf("int.parse got %d, expected %d", gotInt, test.expected)
} }
}) })
} }
} }
func TestBadIntParse(t *testing.T) { func TestBadIntParse(t *testing.T) {
intParser := processor.IntParse{}
tests := []struct { tests := []struct {
processor processor.Processor processor processor.Processor
name string name string
payload any payload any
base int
bitSize int
errorString string errorString string
}{ }{
{ {
name: "non-string input", name: "non-string input",
payload: []byte{0x01}, payload: []byte{0x01},
base: 10,
bitSize: 64,
errorString: "int.parse processor only accepts a string", errorString: "int.parse processor only accepts a string",
}, },
{ {
name: "not int string", name: "not int string",
payload: "123.46", payload: "123.46",
base: 10,
bitSize: 64,
errorString: "strconv.ParseInt: parsing \"123.46\": invalid syntax", 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { 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) got, err := intParser.Process(t.Context(), test.payload)
if err == nil { if err == nil {
t.Errorf("int.parse expected to fail but succeeded, got: %v", got) t.Fatalf("int.parse expected to fail but succeeded, got: %v", got)
} }
if err.Error() != test.errorString { if err.Error() != test.errorString {
t.Errorf("int.parse got error '%s', expected '%s'", 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,175 @@
package processor_test
import (
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestIntRandomFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["int.random"]
if !ok {
t.Fatalf("int.random processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "int.random",
Params: map[string]any{
"min": 1.0,
"max": 10.0,
},
})
if err != nil {
t.Fatalf("failed to create int.random processor: %s", err)
}
if processorInstance.Type() != "int.random" {
t.Fatalf("int.random processor has wrong type: %s", processorInstance.Type())
}
}
func TestIntRandomGoodConfig(t *testing.T) {
registration, ok := processor.ProcessorRegistry["int.random"]
if !ok {
t.Fatalf("int.random processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "int.random",
Params: map[string]any{
"min": 1.0,
"max": 10.0,
},
})
if err != nil {
t.Fatalf("int.random should have created processor but got error: %s", err)
}
payload := "12345"
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("int.random processing failed: %s", err)
}
gotInt, ok := got.(int)
if !ok {
t.Fatalf("int.random returned a %T payload: %s", got, got)
}
if gotInt < 1 || gotInt > 10 {
t.Fatalf("int.random got %d, expected between %d and %d", gotInt, 1, 10)
}
}
func TestGoodIntRandom(t *testing.T) {
tests := []struct {
processor processor.Processor
name string
payload any
min int
max int
}{
{
name: "1-10",
payload: "12345",
min: 1,
max: 10,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
intRandom := processor.IntRandom{
Min: test.min,
Max: test.max,
}
got, err := intRandom.Process(t.Context(), test.payload)
gotInt, ok := got.(int)
if !ok {
t.Fatalf("int.random returned a %T payload: %s", got, got)
}
if err != nil {
t.Fatalf("int.random failed: %s", err)
}
if gotInt < test.min || gotInt > test.max {
t.Fatalf("int.random got %d, expected between %d and %d", gotInt, test.min, test.max)
}
})
}
}
func TestBadIntRandom(t *testing.T) {
tests := []struct {
name string
params map[string]any
payload any
errorString string
}{
{
name: "no min param",
payload: "hello",
params: map[string]any{"max": 10.0},
errorString: "int.random requires a min parameter",
},
{
name: "no max param",
payload: "hello",
params: map[string]any{"min": 1.0},
errorString: "int.random requires a max parameter",
},
{
name: "min param not a number",
payload: "hello",
params: map[string]any{"min": "1", "max": 10.0},
errorString: "int.random min must be a number",
},
{
name: "max param not a number",
payload: "hello",
params: map[string]any{"min": 1.0, "max": "10"},
errorString: "int.random max must be a number",
},
{
name: "max less than min",
payload: "hello",
params: map[string]any{"min": 1.0, "max": 0.0},
errorString: "int.random max must be greater than min",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registration, ok := processor.ProcessorRegistry["int.random"]
if !ok {
t.Fatalf("int.random processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "int.random",
Params: test.params,
})
if err != nil {
if test.errorString != err.Error() {
t.Fatalf("int.random got error '%s', expected '%s'", err.Error(), test.errorString)
}
return
}
got, err := processorInstance.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("int.random expected to fail but got payload: %s", got)
}
if err.Error() != test.errorString {
t.Fatalf("int.random got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -0,0 +1,44 @@
package processor
import (
"context"
"encoding/json"
"errors"
"github.com/jwetzell/showbridge-go/internal/config"
)
type JsonDecode struct {
config config.ProcessorConfig
}
func (jd *JsonDecode) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string)
if !ok {
return nil, errors.New("json.decode processor only accepts a string")
}
payloadJson := make(map[string]any)
err := json.Unmarshal([]byte(payloadString), &payloadJson)
if err != nil {
return nil, err
}
return payloadJson, nil
}
func (jd *JsonDecode) Type() string {
return jd.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "json.decode",
New: func(config config.ProcessorConfig) (Processor, error) {
return &JsonDecode{config: config}, nil
},
})
}

View File

@@ -0,0 +1,91 @@
package processor_test
import (
"reflect"
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestJsonDecodeFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["json.decode"]
if !ok {
t.Fatalf("json.decode processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "json.decode",
})
if err != nil {
t.Fatalf("failed to create json.decode processor: %s", err)
}
if processorInstance.Type() != "json.decode" {
t.Fatalf("json.decode processor has wrong type: %s", processorInstance.Type())
}
payload := "{\"property\":\"hello\"}"
expected := map[string]any{
"property": "hello",
}
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("json.decode processing failed: %s", err)
}
gotMap, ok := got.(map[string]any)
if !ok {
t.Fatalf("json.decode should return byte slice")
}
if !reflect.DeepEqual(gotMap, expected) {
t.Fatalf("json.decode got %+v, expected %+v", got, expected)
}
}
func TestGoodJsonDecode(t *testing.T) {
jsonDecoder := processor.JsonDecode{}
tests := []struct {
name string
payload string
expected map[string]any
}{
{
name: "basic json",
payload: "{\"address\":\"/hello\",\"args\":null}",
expected: map[string]any{
"address": "/hello",
"args": nil,
},
},
{
name: "array",
payload: "{\"address\":\"/hello\",\"args\":[1,2,3]}",
expected: map[string]any{
"address": "/hello",
"args": []any{1.0, 2.0, 3.0},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := jsonDecoder.Process(t.Context(), test.payload)
gotMap, ok := got.(map[string]any)
if !ok {
t.Fatalf("json.decode returned a %T payload: %s", got, got)
}
if err != nil {
t.Fatalf("json.decode failed: %s", err)
}
if !reflect.DeepEqual(gotMap, test.expected) {
t.Fatalf("json.decode got %x, expected %s", got, test.expected)
}
})
}
}

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,85 @@
package processor_test
import (
"slices"
"testing"
"github.com/jwetzell/osc-go"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestJsonEncodeFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["json.encode"]
if !ok {
t.Fatalf("json.encode processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "json.encode",
})
if err != nil {
t.Fatalf("failed to create json.encode processor: %s", err)
}
if processorInstance.Type() != "json.encode" {
t.Fatalf("json.encode processor has wrong type: %s", processorInstance.Type())
}
payload := struct {
Property string `json:"property"`
}{
Property: "hello",
}
expected := []byte("{\"property\":\"hello\"}")
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("json.encode processing failed: %s", err)
}
gotBytes, ok := got.([]byte)
if !ok {
t.Fatalf("json.encode should return byte slice")
}
if !slices.Equal(gotBytes, expected) {
t.Fatalf("json.encode got %+v, expected %+v", got, expected)
}
}
func TestGoodJsonEncode(t *testing.T) {
jsonEncoder := processor.JsonEncode{}
tests := []struct {
name string
payload any
expected []byte
}{
{
name: "basic struct",
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 := jsonEncoder.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

@@ -20,12 +20,12 @@ type MIDIMessageCreate struct {
ProcessFunc func(ctx context.Context, payload any) (any, error) ProcessFunc func(ctx context.Context, payload any) (any, error)
} }
func (mmd *MIDIMessageCreate) Process(ctx context.Context, payload any) (any, error) { func (mmc *MIDIMessageCreate) Process(ctx context.Context, payload any) (any, error) {
return mmd.ProcessFunc(ctx, payload) return mmc.ProcessFunc(ctx, payload)
} }
func (mmd *MIDIMessageCreate) Type() string { func (mmc *MIDIMessageCreate) Type() string {
return mmd.config.Type return mmc.config.Type
} }
func newMidiNoteOnCreate(config config.ProcessorConfig) (Processor, error) { func newMidiNoteOnCreate(config config.ProcessorConfig) (Processor, error) {

View File

@@ -51,6 +51,7 @@ func (mm MQTTMessage) Payload() []byte {
func (mm MQTTMessage) Ack() {} func (mm MQTTMessage) Ack() {}
func (mmc *MQTTMessageCreate) Process(ctx context.Context, payload any) (any, error) { func (mmc *MQTTMessageCreate) Process(ctx context.Context, payload any) (any, error) {
// TODO(jwetzell): support templating
message := MQTTMessage{ message := MQTTMessage{
topic: mmc.Topic, topic: mmc.Topic,

View File

@@ -16,7 +16,7 @@ type NATSMessage struct {
type NATSMessageCreate struct { type NATSMessageCreate struct {
config config.ProcessorConfig config config.ProcessorConfig
Subject string Subject *template.Template
Payload *template.Template Payload *template.Template
} }
@@ -31,8 +31,17 @@ func (nmc *NATSMessageCreate) Process(ctx context.Context, payload any) (any, er
payloadString := payloadBuffer.String() payloadString := payloadBuffer.String()
var subjectBuffer bytes.Buffer
err = nmc.Subject.Execute(&subjectBuffer, payload)
if err != nil {
return nil, err
}
subjectString := subjectBuffer.String()
message := NATSMessage{ message := NATSMessage{
Subject: nmc.Subject, Subject: subjectString,
Payload: []byte(payloadString), Payload: []byte(payloadString),
} }
@@ -48,7 +57,6 @@ func init() {
Type: "nats.message.create", Type: "nats.message.create",
New: func(config config.ProcessorConfig) (Processor, error) { New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params params := config.Params
// TODO(jwetzell): support template for subject
subject, ok := params["subject"] subject, ok := params["subject"]
if !ok { if !ok {
@@ -61,6 +69,12 @@ func init() {
return nil, errors.New("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"] payload, ok := params["payload"]
if !ok { if !ok {
@@ -79,7 +93,7 @@ func init() {
return nil, err return nil, err
} }
return &NATSMessageCreate{config: config, Subject: subjectString, Payload: payloadTemplate}, nil return &NATSMessageCreate{config: config, Subject: subjectTemplate, Payload: payloadTemplate}, nil
}, },
}) })
} }

View File

@@ -20,10 +20,10 @@ type OSCMessageCreate struct {
Types string 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 var addressBuffer bytes.Buffer
err := o.Address.Execute(&addressBuffer, payload) err := omc.Address.Execute(&addressBuffer, payload)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -45,7 +45,7 @@ func (o *OSCMessageCreate) Process(ctx context.Context, payload any) (any, error
args := []osc.OSCArg{} args := []osc.OSCArg{}
for argIndex, argTemplate := range o.Args { for argIndex, argTemplate := range omc.Args {
var argBuffer bytes.Buffer var argBuffer bytes.Buffer
err := argTemplate.Execute(&argBuffer, payload) err := argTemplate.Execute(&argBuffer, payload)
@@ -55,7 +55,7 @@ func (o *OSCMessageCreate) Process(ctx context.Context, payload any) (any, error
argString := argBuffer.String() argString := argBuffer.String()
typedArg, err := argToTypedArg(argString, o.Types[argIndex]) typedArg, err := argToTypedArg(argString, omc.Types[argIndex])
if err != nil { if err != nil {
return nil, err return nil, err
@@ -71,8 +71,8 @@ func (o *OSCMessageCreate) Process(ctx context.Context, payload any) (any, error
return payloadMessage, nil return payloadMessage, nil
} }
func (o *OSCMessageCreate) Type() string { func (omc *OSCMessageCreate) Type() string {
return o.config.Type return omc.config.Type
} }
func init() { func init() {

View File

@@ -12,7 +12,7 @@ type OSCMessageDecode struct {
config 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) payloadBytes, ok := payload.([]byte)
if !ok { if !ok {
@@ -34,8 +34,8 @@ func (o *OSCMessageDecode) Process(ctx context.Context, payload any) (any, error
return message, nil return message, nil
} }
func (o *OSCMessageDecode) Type() string { func (omd *OSCMessageDecode) Type() string {
return o.config.Type return omd.config.Type
} }
func init() { func init() {

View File

@@ -12,7 +12,7 @@ type OSCMessageEncode struct {
config config.ProcessorConfig config config.ProcessorConfig
} }
func (o *OSCMessageEncode) Process(ctx context.Context, payload any) (any, error) { func (ome *OSCMessageEncode) Process(ctx context.Context, payload any) (any, error) {
payloadMessage, ok := payload.(osc.OSCMessage) payloadMessage, ok := payload.(osc.OSCMessage)
if !ok { if !ok {
@@ -23,8 +23,8 @@ func (o *OSCMessageEncode) Process(ctx context.Context, payload any) (any, error
return bytes, nil return bytes, nil
} }
func (o *OSCMessageEncode) Type() string { func (ome *OSCMessageEncode) Type() string {
return o.config.Type return ome.config.Type
} }
func init() { func init() {

View File

@@ -16,7 +16,7 @@ type OSCMessageFilter struct {
Address *regexp.Regexp 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) payloadMessage, ok := payload.(osc.OSCMessage)
@@ -24,15 +24,15 @@ func (o *OSCMessageFilter) Process(ctx context.Context, payload any) (any, error
return nil, errors.New("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 nil, nil
} }
return payloadMessage, nil return payloadMessage, nil
} }
func (o *OSCMessageFilter) Type() string { func (omf *OSCMessageFilter) Type() string {
return o.config.Type return omf.config.Type
} }
func init() { func init() {

View File

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

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

@@ -4,9 +4,83 @@ import (
"testing" "testing"
"github.com/expr-lang/expr" "github.com/expr-lang/expr"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor" "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) { func TestGoodScriptExpr(t *testing.T) {
tests := []struct { tests := []struct {
program string program string
@@ -38,7 +112,7 @@ func TestGoodScriptExpr(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
program, err := expr.Compile(test.program) program, err := expr.Compile(test.program)
if err != nil { if err != nil {
t.Errorf("script.expr failed to compile program: %s", err) t.Fatalf("script.expr failed to compile program: %s", err)
} }
exprProcessor := &processor.ScriptExpr{Program: program} exprProcessor := &processor.ScriptExpr{Program: program}
@@ -46,12 +120,51 @@ func TestGoodScriptExpr(t *testing.T) {
got, err := exprProcessor.Process(t.Context(), test.payload) got, err := exprProcessor.Process(t.Context(), test.payload)
if err != nil { if err != nil {
t.Errorf("script.expr failed: %s", err) t.Fatalf("script.expr failed: %s", err)
} }
//TODO(jwetzell): work out better way to compare the any/any //TODO(jwetzell): work out better way to compare the any/any
if got != test.expected { if got != test.expected {
t.Errorf("script.expr got %+v (%T), expected %+v (%T)", got, got, test.expected, 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,11 +1,82 @@
package processor_test package processor_test
import ( import (
"maps"
"testing" "testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor" "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) { func TestGoodScriptJS(t *testing.T) {
tests := []struct { tests := []struct {
processor processor.Processor processor processor.Processor
@@ -14,21 +85,37 @@ func TestGoodScriptJS(t *testing.T) {
expected any expected any
}{ }{
{ {
name: "number",
processor: &processor.ScriptJS{Program: ` processor: &processor.ScriptJS{Program: `
payload = payload + 1 payload = payload + 1
`}, `},
name: "number",
payload: 1, payload: 1,
expected: 2, expected: 2,
}, },
{ {
name: "string",
processor: &processor.ScriptJS{Program: ` processor: &processor.ScriptJS{Program: `
payload = payload + "1" payload = payload + "1"
`}, `},
name: "string",
payload: "1", payload: "1",
expected: "11", 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 { for _, test := range tests {
@@ -36,12 +123,56 @@ func TestGoodScriptJS(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload) got, err := test.processor.Process(t.Context(), test.payload)
if err != nil { if err != nil {
t.Errorf("script.js failed: %s", err) t.Fatalf("script.js process failed: %s", err)
} }
//TODO(jwetzell): work out better way to compare the any/any //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 { if got != test.expected {
t.Errorf("script.js got %+v, expected %+v", 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

@@ -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.create got error '%s', expected '%s'", err.Error(), test.errorString)
}
return
}
got, err := processorInstance.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("string.create expected to fail but got payload: %s", got)
}
if err.Error() != test.errorString {
t.Fatalf("string.create got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -3,20 +3,49 @@ package processor_test
import ( import (
"testing" "testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor" "github.com/jwetzell/showbridge-go/internal/processor"
) )
func TestStringDecodeFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["string.decode"]
if !ok {
t.Fatalf("string.decode processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "string.decode",
})
if err != nil {
t.Fatalf("failed to create string.decode processor: %s", err)
}
if processorInstance.Type() != "string.decode" {
t.Fatalf("string.decode processor has wrong type: %s", processorInstance.Type())
}
payload := []byte{'h', 'e', 'l', 'l', 'o'}
expected := "hello"
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("string.decode processing failed: %s", err)
}
if got != expected {
t.Fatalf("string.decode got %+v, expected %+v", got, expected)
}
}
func TestGoodStringDecode(t *testing.T) { func TestGoodStringDecode(t *testing.T) {
stringDecoder := processor.StringDecode{} stringDecoder := processor.StringDecode{}
tests := []struct { tests := []struct {
processor processor.Processor
name string name string
payload any payload any
expected string expected string
}{ }{
{ {
processor: &stringDecoder, name: "basic string",
name: "hello",
payload: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f}, payload: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f},
expected: "hello", expected: "hello",
}, },
@@ -24,17 +53,17 @@ func TestGoodStringDecode(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload) got, err := stringDecoder.Process(t.Context(), test.payload)
gotString, ok := got.(string) gotString, ok := got.(string)
if !ok { if !ok {
t.Errorf("string.decode returned a %T payload: %s", got, got) t.Fatalf("string.decode returned a %T payload: %s", got, got)
} }
if err != nil { if err != nil {
t.Errorf("string.decode failed: %s", err) t.Fatalf("string.decode failed: %s", err)
} }
if gotString != test.expected { if gotString != test.expected {
t.Errorf("string.decode got %s, expected %s", got, test.expected) t.Fatalf("string.decode got %s, expected %s", got, test.expected)
} }
}) })
} }
@@ -43,13 +72,11 @@ func TestGoodStringDecode(t *testing.T) {
func TestBadStringDecode(t *testing.T) { func TestBadStringDecode(t *testing.T) {
stringDecoder := processor.StringDecode{} stringDecoder := processor.StringDecode{}
tests := []struct { tests := []struct {
processor processor.Processor
name string name string
payload any payload any
errorString string errorString string
}{ }{
{ {
processor: &stringDecoder,
name: "non-[]byte input", name: "non-[]byte input",
payload: "hello", payload: "hello",
errorString: "string.decode processor only accepts a []byte", errorString: "string.decode processor only accepts a []byte",
@@ -58,13 +85,13 @@ func TestBadStringDecode(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload) got, err := stringDecoder.Process(t.Context(), test.payload)
if err == nil { if err == nil {
t.Errorf("string.decode expected to fail but got payload: %s", got) t.Fatalf("string.decode expected to fail but got payload: %s", got)
} }
if err.Error() != test.errorString { if err.Error() != test.errorString {
t.Errorf("string.decode got error '%s', expected '%s'", err.Error(), test.errorString) t.Fatalf("string.decode got error '%s', expected '%s'", err.Error(), test.errorString)
} }
}) })
} }

View File

@@ -4,20 +4,55 @@ import (
"slices" "slices"
"testing" "testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor" "github.com/jwetzell/showbridge-go/internal/processor"
) )
func TestStringEncodeFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["string.encode"]
if !ok {
t.Fatalf("string.encode processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "string.encode",
})
if err != nil {
t.Fatalf("failed to create string.encode processor: %s", err)
}
if processorInstance.Type() != "string.encode" {
t.Fatalf("string.encode processor has wrong type: %s", processorInstance.Type())
}
payload := "hello"
expected := []byte{'h', 'e', 'l', 'l', 'o'}
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("string.encode processing failed: %s", err)
}
gotBytes, ok := got.([]byte)
if !ok {
t.Fatalf("string.encode should return byte slice")
}
if !slices.Equal(gotBytes, expected) {
t.Fatalf("string.encode got %+v, expected %+v", got, expected)
}
}
func TestGoodStringEncode(t *testing.T) { func TestGoodStringEncode(t *testing.T) {
stringEncoder := processor.StringEncode{} stringEncoder := processor.StringEncode{}
tests := []struct { tests := []struct {
processor processor.Processor
name string name string
payload any payload any
expected []byte expected []byte
}{ }{
{ {
processor: &stringEncoder, name: "basic string",
name: "hello",
payload: "hello", payload: "hello",
expected: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f}, expected: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f},
}, },
@@ -25,17 +60,17 @@ func TestGoodStringEncode(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload) got, err := stringEncoder.Process(t.Context(), test.payload)
gotBytes, ok := got.([]byte) gotBytes, ok := got.([]byte)
if !ok { if !ok {
t.Errorf("string.encode returned a %T payload: %s", got, got) t.Fatalf("string.encode returned a %T payload: %s", got, got)
} }
if err != nil { if err != nil {
t.Errorf("string.encode failed: %s", err) t.Fatalf("string.encode failed: %s", err)
} }
if !slices.Equal(gotBytes, test.expected) { if !slices.Equal(gotBytes, test.expected) {
t.Errorf("string.encode got %s, expected %s", got, test.expected) t.Fatalf("string.encode got %s, expected %s", got, test.expected)
} }
}) })
} }
@@ -44,13 +79,11 @@ func TestGoodStringEncode(t *testing.T) {
func TestBadStringEncode(t *testing.T) { func TestBadStringEncode(t *testing.T) {
stringEncoder := processor.StringEncode{} stringEncoder := processor.StringEncode{}
tests := []struct { tests := []struct {
processor processor.Processor
name string name string
payload any payload any
errorString string errorString string
}{ }{
{ {
processor: &stringEncoder,
name: "non-string input", name: "non-string input",
payload: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f}, payload: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f},
errorString: "string.encode processor only accepts a string", errorString: "string.encode processor only accepts a string",
@@ -59,13 +92,13 @@ func TestBadStringEncode(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload) got, err := stringEncoder.Process(t.Context(), test.payload)
if err == nil { if err == nil {
t.Errorf("string.encode expected to fail but got payload: %s", got) t.Fatalf("string.encode expected to fail but got payload: %s", got)
} }
if err.Error() != test.errorString { if err.Error() != test.errorString {
t.Errorf("string.encode got error '%s', expected '%s'", err.Error(), test.errorString) t.Fatalf("string.encode got error '%s', expected '%s'", err.Error(), test.errorString)
} }
}) })
} }

View File

@@ -13,22 +13,22 @@ type StringFilter struct {
Pattern *regexp.Regexp Pattern *regexp.Regexp
} }
func (se *StringFilter) Process(ctx context.Context, payload any) (any, error) { func (sf *StringFilter) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string) payloadString, ok := payload.(string)
if !ok { if !ok {
return nil, errors.New("string.filter processor only accepts a string") return nil, errors.New("string.filter processor only accepts a string")
} }
if !se.Pattern.MatchString(payloadString) { if !sf.Pattern.MatchString(payloadString) {
return nil, nil return nil, nil
} }
return payloadString, nil return payloadString, nil
} }
func (se *StringFilter) Type() string { func (sf *StringFilter) Type() string {
return se.config.Type return sf.config.Type
} }
func init() { func init() {

View File

@@ -0,0 +1,187 @@
package processor_test
import (
"reflect"
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestStringFilterFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["string.filter"]
if !ok {
t.Fatalf("string.filter processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "string.filter",
Params: map[string]any{
"pattern": "hello",
},
})
if err != nil {
t.Fatalf("failed to create string.filter processor: %s", err)
}
if processorInstance.Type() != "string.filter" {
t.Fatalf("string.filter processor has wrong type: %s", processorInstance.Type())
}
payload := "hello"
expected := "hello"
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("string.filter processing failed: %s", err)
}
gotString, ok := got.(string)
if !ok {
t.Fatalf("string.filter should return byte slice")
}
if gotString != expected {
t.Fatalf("string.filter got %+v, expected %+v", got, expected)
}
}
func TestGoodStringFilter(t *testing.T) {
tests := []struct {
name string
params map[string]any
payload string
expected any
}{
{
name: "matches pattern",
payload: "hello",
params: map[string]any{"pattern": "hello"},
expected: "hello",
},
{
name: "does not match pattern",
payload: "hello",
params: map[string]any{"pattern": "world"},
expected: nil,
},
{
name: "basic regex",
payload: "hello world",
params: map[string]any{"pattern": ".* world"},
expected: "hello world",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registration, ok := processor.ProcessorRegistry["string.filter"]
if !ok {
t.Fatalf("string.filter processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "string.filter",
Params: test.params,
})
if err != nil {
t.Fatalf("string.filter failed to create processor: %s", err)
}
got, err := processorInstance.Process(t.Context(), test.payload)
if err != nil {
t.Fatalf("string.filter failed: %s", err)
}
if test.expected == nil {
if got != nil {
t.Fatalf("string.filter got %+v, expected nil", got)
}
return
}
gotString, ok := got.(string)
if !ok {
t.Fatalf("string.filter returned a %T payload: %s", got, got)
}
if !reflect.DeepEqual(gotString, test.expected) {
t.Fatalf("string.filter got %+v, expected %+v", gotString, test.expected)
}
})
}
}
func TestBadStringFilter(t *testing.T) {
tests := []struct {
name string
payload any
params map[string]any
errorString string
}{
{
name: "no pattern param",
payload: "hello",
params: map[string]any{},
errorString: "string.filter requires a pattern parameter",
},
{
name: "non-string input",
payload: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f},
params: map[string]any{
"pattern": "hello",
},
errorString: "string.filter processor only accepts a string",
},
{
name: "non-string pattern param",
payload: "hello",
params: map[string]any{
"pattern": 123,
},
errorString: "string.filter pattern must be a string",
},
{
name: "invalid regex pattern",
payload: "hello",
params: map[string]any{
"pattern": "*invalid",
},
errorString: "error parsing regexp: missing argument to repetition operator: `*`",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registration, ok := processor.ProcessorRegistry["string.filter"]
if !ok {
t.Fatalf("string.filter processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "string.filter",
Params: test.params,
})
if err != nil {
if test.errorString != err.Error() {
t.Fatalf("string.filter got error '%s', expected '%s'", err.Error(), test.errorString)
}
return
}
got, err := processorInstance.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("string.filter expected to fail but got payload: %s", got)
}
if err.Error() != test.errorString {
t.Fatalf("string.filter got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -13,20 +13,20 @@ type StringSplit struct {
Separator string Separator string
} }
func (se *StringSplit) Process(ctx context.Context, payload any) (any, error) { func (ss *StringSplit) Process(ctx context.Context, payload any) (any, error) {
payloadString, ok := payload.(string) payloadString, ok := payload.(string)
if !ok { if !ok {
return nil, errors.New("string.split only accepts a string") return nil, errors.New("string.split only accepts a string")
} }
payloadParts := strings.Split(payloadString, se.Separator) payloadParts := strings.Split(payloadString, ss.Separator)
return payloadParts, nil return payloadParts, nil
} }
func (se *StringSplit) Type() string { func (ss *StringSplit) Type() string {
return se.config.Type return ss.config.Type
} }
func init() { func init() {

View File

@@ -4,9 +4,49 @@ import (
"slices" "slices"
"testing" "testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor" "github.com/jwetzell/showbridge-go/internal/processor"
) )
func TestStringSplitFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["string.split"]
if !ok {
t.Fatalf("string.split processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "string.split",
Params: map[string]any{
"separator": ",",
},
})
if err != nil {
t.Fatalf("failed to create string.split processor: %s", err)
}
if processorInstance.Type() != "string.split" {
t.Fatalf("string.split processor has wrong type: %s", processorInstance.Type())
}
payload := "part1,part2,part3"
expected := []string{"part1", "part2", "part3"}
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("string.split processing failed: %s", err)
}
gotStrings, ok := got.([]string)
if !ok {
t.Fatalf("string.split should return a slice of strings")
}
if !slices.Equal(gotStrings, expected) {
t.Fatalf("string.split got %+v, expected %+v", got, expected)
}
}
func TestGoodStringSplit(t *testing.T) { func TestGoodStringSplit(t *testing.T) {
tests := []struct { tests := []struct {
processor processor.Processor processor processor.Processor
@@ -28,13 +68,13 @@ func TestGoodStringSplit(t *testing.T) {
gotStrings, ok := got.([]string) gotStrings, ok := got.([]string)
if !ok { if !ok {
t.Errorf("string.split returned a %T payload: %s", got, got) t.Fatalf("string.split returned a %T payload: %s", got, got)
} }
if err != nil { if err != nil {
t.Errorf("string.split failed: %s", err) t.Fatalf("string.split failed: %s", err)
} }
if !slices.Equal(gotStrings, test.expected) { if !slices.Equal(gotStrings, test.expected) {
t.Errorf("string.split got %s, expected %s", got, test.expected) t.Fatalf("string.split got %s, expected %s", got, test.expected)
} }
}) })
} }
@@ -42,28 +82,57 @@ func TestGoodStringSplit(t *testing.T) {
func TestBadStringSplit(t *testing.T) { func TestBadStringSplit(t *testing.T) {
tests := []struct { tests := []struct {
processor processor.Processor
name string name string
payload any payload any
params map[string]any
errorString string errorString string
}{ }{
{ {
processor: &processor.StringSplit{Separator: ","}, name: "non-string input",
name: "hello",
payload: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f}, payload: []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f},
params: map[string]any{"separator": ","},
errorString: "string.split only accepts a string", errorString: "string.split only accepts a string",
}, },
{
name: "missing separator param",
payload: "part1,part2,part3",
params: map[string]any{},
errorString: "string.split requires a separator",
},
{
name: "non-string separator param",
payload: "part1,part2,part3",
params: map[string]any{"separator": 123},
errorString: "string.split separator must be a string",
},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
got, err := test.processor.Process(t.Context(), test.payload) registration, ok := processor.ProcessorRegistry["string.split"]
if !ok {
t.Fatalf("string.split processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "string.split",
Params: test.params,
})
if err != nil {
if test.errorString != err.Error() {
t.Fatalf("string.split got error '%s', expected '%s'", err.Error(), test.errorString)
}
return
}
got, err := processorInstance.Process(t.Context(), test.payload)
if err == nil { if err == nil {
t.Errorf("string.split expected error but got none, payload: %s", got) t.Fatalf("string.split expected error but got none, payload: %s", got)
} }
if err.Error() != test.errorString { if err.Error() != test.errorString {
t.Errorf("string.split got error '%s', expected '%s'", err.Error(), test.errorString) t.Fatalf("string.split got error '%s', expected '%s'", err.Error(), test.errorString)
} }
}) })
} }

View File

@@ -0,0 +1,45 @@
package processor
import (
"context"
"errors"
"time"
"github.com/jwetzell/showbridge-go/internal/config"
)
type MetaDelay struct {
config config.ProcessorConfig
Duration time.Duration
}
func (md *MetaDelay) Process(ctx context.Context, payload any) (any, error) {
time.Sleep(md.Duration)
return payload, nil
}
func (md *MetaDelay) Type() string {
return md.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "time.sleep",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
duration, ok := params["duration"]
if !ok {
return nil, errors.New("time.sleep requires a duration parameter")
}
durationNum, ok := duration.(float64)
if !ok {
return nil, errors.New("time.sleep duration must be number")
}
return &MetaDelay{config: config, Duration: time.Millisecond * time.Duration(durationNum)}, nil
},
})
}

View File

@@ -0,0 +1,30 @@
package processor_test
import (
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestTimeSleepFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["time.sleep"]
if !ok {
t.Fatalf("time.sleep processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "time.sleep",
Params: map[string]any{
"duration": 1000.0,
},
})
if err != nil {
t.Fatalf("failed to create time.sleep processor: %s", err)
}
if processorInstance.Type() != "time.sleep" {
t.Fatalf("time.sleep processor has wrong type: %s", processorInstance.Type())
}
}

View File

@@ -3,42 +3,158 @@ package processor_test
import ( import (
"testing" "testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor" "github.com/jwetzell/showbridge-go/internal/processor"
) )
func TestUintParseFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["uint.parse"]
if !ok {
t.Fatalf("uint.parse processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "uint.parse",
})
if err != nil {
t.Fatalf("failed to create uint.parse processor: %s", err)
}
if processorInstance.Type() != "uint.parse" {
t.Fatalf("uint.parse processor has wrong type: %s", processorInstance.Type())
}
}
func TestUintParseBadConfigBaseString(t *testing.T) {
registration, ok := processor.ProcessorRegistry["uint.parse"]
if !ok {
t.Fatalf("uint.parse processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "uint.parse",
Params: map[string]any{
"base": "10",
},
})
if err == nil {
t.Fatalf("uint.parse should have returned an error for bad base config")
}
}
func TestUintParseBadConfigBitSizeString(t *testing.T) {
registration, ok := processor.ProcessorRegistry["uint.parse"]
if !ok {
t.Fatalf("uint.parse processor not registered")
}
_, err := registration.New(config.ProcessorConfig{
Type: "uint.parse",
Params: map[string]any{
"bitSize": "64",
},
})
if err == nil {
t.Fatalf("uint.parse should have returned an error for bad bitSize config")
}
}
func TestUintParseGoodConfig(t *testing.T) {
registration, ok := processor.ProcessorRegistry["uint.parse"]
if !ok {
t.Fatalf("uint.parse processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "uint.parse",
Params: map[string]any{
"base": 10.0,
"bitSize": 64.0,
},
})
if err != nil {
t.Fatalf("uint.parse should have created processor but got error: %s", err)
}
payload := "12345"
expected := uint64(12345)
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("uint.parse processing failed: %s", err)
}
gotUint, ok := got.(uint64)
if !ok {
t.Fatalf("uint.parse returned a %T payload: %s", got, got)
}
if gotUint != expected {
t.Fatalf("uint.parse got %d, expected %d", gotUint, expected)
}
}
func TestGoodUintParse(t *testing.T) { func TestGoodUintParse(t *testing.T) {
uintParser := processor.UintParse{}
tests := []struct { tests := []struct {
processor processor.Processor processor processor.Processor
name string name string
payload any payload any
expected uint64 expected uint64
base int
bitSize int
}{ }{
{ {
name: "positive number", name: "positive number",
payload: "12345", payload: "12345",
expected: 12345, expected: 12345,
base: 10,
bitSize: 64,
}, },
{ {
name: "zero", name: "zero",
payload: "0", payload: "0",
expected: 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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
uintParser := processor.UintParse{
Base: test.base,
BitSize: test.bitSize,
}
got, err := uintParser.Process(t.Context(), test.payload) got, err := uintParser.Process(t.Context(), test.payload)
gotUint, ok := got.(uint64) gotUint, ok := got.(uint64)
if !ok { if !ok {
t.Errorf("uint.parse returned a %T payload: %s", got, got) t.Fatalf("uint.parse returned a %T payload: %s", got, got)
} }
if err != nil { if err != nil {
t.Errorf("uint.parse failed: %s", err) t.Fatalf("uint.parse failed: %s", err)
} }
if gotUint != test.expected { if gotUint != test.expected {
t.Errorf("uint.parse got %d, expected %d", gotUint, test.expected) t.Fatalf("uint.parse got %d, expected %d", gotUint, test.expected)
} }
}) })
} }
@@ -50,18 +166,31 @@ func TestBadUintParse(t *testing.T) {
processor processor.Processor processor processor.Processor
name string name string
payload any payload any
base int
bitSize int
errorString string errorString string
}{ }{
{ {
name: "non-string input", name: "non-string input",
payload: []byte{0x01}, payload: []byte{0x01},
base: 10,
bitSize: 64,
errorString: "uint.parse processor only accepts a string", errorString: "uint.parse processor only accepts a string",
}, },
{ {
name: "not uint string", name: "not uint string",
payload: "-1234", payload: "-1234",
base: 10,
bitSize: 64,
errorString: "strconv.ParseUint: parsing \"-1234\": invalid syntax", errorString: "strconv.ParseUint: parsing \"-1234\": invalid syntax",
}, },
{
name: "bit overflow",
payload: "123456789012345678901234567",
base: 10,
bitSize: 32,
errorString: "strconv.ParseUint: parsing \"123456789012345678901234567\": value out of range",
},
} }
for _, test := range tests { for _, test := range tests {
@@ -69,11 +198,11 @@ func TestBadUintParse(t *testing.T) {
got, err := uintParser.Process(t.Context(), test.payload) got, err := uintParser.Process(t.Context(), test.payload)
if err == nil { if err == nil {
t.Errorf("uint.parse expected to fail but succeeded, got: %v", got) t.Fatalf("uint.parse expected to fail but succeeded, got: %v", got)
} }
if err.Error() != test.errorString { if err.Error() != test.errorString {
t.Errorf("uint.parse got error '%s', expected '%s'", err.Error(), test.errorString) t.Fatalf("uint.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 UintRandom struct {
Min uint
Max uint
config config.ProcessorConfig
}
func (ur *UintRandom) Process(ctx context.Context, payload any) (any, error) {
payloadInt := rand.UintN(ur.Max-ur.Min+1) + ur.Min
return payloadInt, nil
}
func (ur *UintRandom) Type() string {
return ur.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "uint.random",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
min, ok := params["min"]
if !ok {
return nil, errors.New("uint.random requires a min parameter")
}
minFloat, ok := min.(float64)
if !ok {
return nil, errors.New("uint.random min must be a number")
}
max, ok := params["max"]
if !ok {
return nil, errors.New("uint.random requires a max parameter")
}
maxFloat, ok := max.(float64)
if !ok {
return nil, errors.New("uint.random max must be a number")
}
if maxFloat < minFloat {
return nil, errors.New("uint.random max must be greater than min")
}
return &UintRandom{config: config, Min: uint(minFloat), Max: uint(maxFloat)}, nil
},
})
}

View File

@@ -0,0 +1,177 @@
package processor_test
import (
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
)
func TestUintRandomFromRegistry(t *testing.T) {
registration, ok := processor.ProcessorRegistry["uint.random"]
if !ok {
t.Fatalf("uint.random processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "uint.random",
Params: map[string]any{
"min": 1.0,
"max": 10.0,
},
})
if err != nil {
t.Fatalf("failed to create uint.random processor: %s", err)
}
if processorInstance.Type() != "uint.random" {
t.Fatalf("uint.random processor has wrong type: %s", processorInstance.Type())
}
}
func TestUintRandomGoodConfig(t *testing.T) {
registration, ok := processor.ProcessorRegistry["uint.random"]
if !ok {
t.Fatalf("uint.random processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "uint.random",
Params: map[string]any{
"min": 1.0,
"max": 10.0,
},
})
if err != nil {
t.Fatalf("uint.random should have created processor but got error: %s", err)
}
payload := "12345"
got, err := processorInstance.Process(t.Context(), payload)
if err != nil {
t.Fatalf("uint.random processing failed: %s", err)
}
gotUint, ok := got.(uint)
if !ok {
t.Fatalf("uint.random returned a %T payload: %s", got, got)
}
if gotUint < 1 || gotUint > 10 {
t.Fatalf("uint.random got %d, expected between %d and %d", gotUint, 1, 10)
}
}
func TestGoodUintRandom(t *testing.T) {
tests := []struct {
processor processor.Processor
name string
payload any
min uint
max uint
}{
{
name: "1-10",
payload: "12345",
min: 1,
max: 10,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
uintRandom := processor.UintRandom{
Min: test.min,
Max: test.max,
}
got, err := uintRandom.Process(t.Context(), test.payload)
gotUint, ok := got.(uint)
if !ok {
t.Fatalf("uint.random returned a %T payload: %s", got, got)
}
if err != nil {
t.Fatalf("uint.random failed: %s", err)
}
if gotUint < test.min || gotUint > test.max {
t.Fatalf("uint.random got %d, expected between %d and %d", gotUint, test.min, test.max)
}
})
}
}
func TestBadUintRandom(t *testing.T) {
tests := []struct {
name string
params map[string]any
payload any
errorString string
}{
{
name: "no min param",
payload: "hello",
params: map[string]any{"max": 10.0},
errorString: "uint.random requires a min parameter",
},
{
name: "no max param",
payload: "hello",
params: map[string]any{"min": 1.0},
errorString: "uint.random requires a max parameter",
},
{
name: "min param not a number",
payload: "hello",
params: map[string]any{"min": "1", "max": 10.0},
errorString: "uint.random min must be a number",
},
{
name: "max param not a number",
payload: "hello",
params: map[string]any{"min": 1.0, "max": "10"},
errorString: "uint.random max must be a number",
},
{
name: "max less than min",
payload: "hello",
params: map[string]any{"min": 1.0, "max": 0.0},
errorString: "uint.random max must be greater than min",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registration, ok := processor.ProcessorRegistry["uint.random"]
if !ok {
t.Fatalf("uint.random processor not registered")
}
processorInstance, err := registration.New(config.ProcessorConfig{
Type: "uint.random",
Params: test.params,
})
if err != nil {
if test.errorString != err.Error() {
t.Fatalf("uint.random got error '%s', expected '%s'", err.Error(), test.errorString)
}
return
}
got, err := processorInstance.Process(t.Context(), test.payload)
if err == nil {
t.Fatalf("uint.random expected to fail but got payload: %s", got)
}
if err.Error() != test.errorString {
t.Fatalf("uint.random got error '%s', expected '%s'", err.Error(), test.errorString)
}
})
}
}

View File

@@ -6,8 +6,16 @@ import (
"github.com/jwetzell/showbridge-go/internal/config" "github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor" "github.com/jwetzell/showbridge-go/internal/processor"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
) )
type routeContextKey string
var RouterContextKey routeContextKey = routeContextKey("router")
var SourceContextKey routeContextKey = routeContextKey("source")
type RouteError struct { type RouteError struct {
Index int Index int
Config config.RouteConfig Config config.RouteConfig
@@ -16,19 +24,20 @@ type RouteError struct {
type RouteIOError struct { type RouteIOError struct {
Index int Index int
Error error OutputError error
ProcessError error
InputError error
} }
type RouteIO interface { type RouteIO interface {
HandleInput(sourceId string, payload any) []RouteIOError HandleInput(ctx context.Context, sourceId string, payload any) (bool, []RouteIOError)
HandleOutput(sourceId string, destinationId string, payload any) error HandleOutput(ctx context.Context, destinationId string, payload any) error
} }
type Route interface { type Route interface {
Input() string Input() string
Output() string Output() string
HandleInput(ctx context.Context, sourceId string, payload any, router RouteIO) error ProcessPayload(ctx context.Context, payload any) (any, error)
HandleOutput(ctx context.Context, sourceId string, payload any, router RouteIO) error
} }
type ProcessorRoute struct { type ProcessorRoute struct {
@@ -66,21 +75,33 @@ func (r *ProcessorRoute) Output() string {
return r.output return r.output
} }
func (r *ProcessorRoute) HandleInput(ctx context.Context, sourceId string, payload any, router RouteIO) error { func (r *ProcessorRoute) ProcessPayload(ctx context.Context, payload any) (any, error) {
var err error parentSpan := trace.SpanFromContext(ctx)
for _, processor := range r.processors { tracer := parentSpan.TracerProvider().Tracer("route.ProcessPayload")
payload, err = processor.Process(ctx, payload) processCtx, processSpan := tracer.Start(ctx, "route.process")
defer processSpan.End()
for processorIndex, processor := range r.processors {
processorCtx, processorSpan := tracer.Start(processCtx, "route.processor", trace.WithAttributes(attribute.Int("processor.index", processorIndex), attribute.String("processor.type", processor.Type())))
processedPayload, err := processor.Process(processorCtx, payload)
if err != nil { if err != nil {
return err processorSpan.SetStatus(codes.Error, "route processor error")
processorSpan.RecordError(err)
processorSpan.End()
processSpan.SetStatus(codes.Error, "route processing error")
processSpan.RecordError(err)
return nil, err
} }
processorSpan.SetStatus(codes.Ok, "processor successful")
//NOTE(jwetzell) nil payload will result in the route being "terminated" //NOTE(jwetzell) nil payload will result in the route being "terminated"
if payload == nil { if processedPayload == nil {
return nil processSpan.SetStatus(codes.Ok, "route processing terminated early due to nil payload")
processorSpan.End()
return nil, nil
} }
payload = processedPayload
processorSpan.End()
} }
return r.HandleOutput(ctx, sourceId, payload, router) processSpan.SetStatus(codes.Ok, "route processing successful")
}
func (r *ProcessorRoute) HandleOutput(ctx context.Context, sourceId string, payload any, router RouteIO) error { return payload, nil
return router.HandleOutput(sourceId, r.output, payload)
} }

View File

@@ -0,0 +1,166 @@
package route_test
import (
"context"
"slices"
"testing"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/route"
)
func TestRouteCreate(t *testing.T) {
routeConfig := config.RouteConfig{
Input: "input",
Output: "output",
}
testRoute, err := route.NewRoute(routeConfig)
if err != nil {
t.Fatalf("route failed to create: %v", err)
}
if testRoute.Input() != routeConfig.Input {
t.Fatalf("route input does not match expected input")
}
if testRoute.Output() != routeConfig.Output {
t.Fatalf("route output does not match expected output")
}
}
type MockRouter struct{}
func (mr *MockRouter) HandleInput(ctx context.Context, sourceId string, payload any) (bool, []route.RouteIOError) {
return false, []route.RouteIOError{}
}
func (mr *MockRouter) HandleOutput(ctx context.Context, destinationId string, payload any) error {
return nil
}
func TestGoodRouteHandleInput(t *testing.T) {
routeConfig := config.RouteConfig{
Input: "input",
Processors: []config.ProcessorConfig{
{Type: "string.encode"},
},
Output: "output",
}
testRoute, err := route.NewRoute(routeConfig)
if err != nil {
t.Fatalf("route failed to create: %v", err)
}
inputData := "test input data"
payload, err := testRoute.ProcessPayload(context.WithValue(t.Context(), route.RouterContextKey, &MockRouter{}), inputData)
if err != nil {
t.Fatalf("route ProcessPayload returned error: %v", err)
}
payloadBytes, ok := payload.([]byte)
if !ok {
t.Fatalf("payload should be []byte got %T", payload)
}
if !slices.Equal([]byte(inputData), payloadBytes) {
t.Fatalf("route returned the wrong payload. expected: %+v got %+v", inputData, payloadBytes)
}
}
func TestRouteHandleInputWithProcessorError(t *testing.T) {
routeConfig := config.RouteConfig{
Input: "input",
Processors: []config.ProcessorConfig{
{Type: "string.create", Params: map[string]any{"template": "{{.invalid}}}"}},
},
Output: "output",
}
testRoute, err := route.NewRoute(routeConfig)
if err != nil {
t.Fatalf("route failed to create: %v", err)
}
inputData := "test input data"
_, err = testRoute.ProcessPayload(context.WithValue(t.Context(), route.RouterContextKey, &MockRouter{}), inputData)
if err == nil {
t.Fatalf("route HandleOutput did not return error for bad processor")
}
}
func TestRouteHandleNilPayload(t *testing.T) {
routeConfig := config.RouteConfig{
Input: "input",
Processors: []config.ProcessorConfig{},
Output: "output",
}
testRoute, err := route.NewRoute(routeConfig)
if err != nil {
t.Fatalf("route failed to create: %v", err)
return
}
payload, err := testRoute.ProcessPayload(context.WithValue(t.Context(), route.RouterContextKey, &MockRouter{}), nil)
if err != nil {
t.Fatalf("route ProcessPayload returned error: %v", err)
}
if payload != nil {
t.Fatalf("route returned the wrong payload")
}
}
func TestRouteHandleNilPayloadFromProcessor(t *testing.T) {
routeConfig := config.RouteConfig{
Input: "input",
Processors: []config.ProcessorConfig{
{Type: "script.js", Params: map[string]any{"program": "payload = undefined"}},
},
Output: "output",
}
testRoute, err := route.NewRoute(routeConfig)
if err != nil {
t.Fatalf("route failed to create: %v", err)
}
payload, err := testRoute.ProcessPayload(context.WithValue(t.Context(), route.RouterContextKey, &MockRouter{}), "test")
if err != nil {
t.Fatalf("route HandleOutput returned error for nil payload: %v", err)
}
if payload != nil {
t.Fatalf("route returned the wrong payload")
}
}
func TestRouteUnknownProcessor(t *testing.T) {
routeConfig := config.RouteConfig{
Input: "input",
Processors: []config.ProcessorConfig{
{Type: "asdfasdflkjalkj"},
},
Output: "output",
}
_, err := route.NewRoute(routeConfig)
if err == nil {
t.Fatalf("route error expected when creating route with an unknown processor, got nil")
}
}
func TestRouteBadProcessorConfig(t *testing.T) {
routeConfig := config.RouteConfig{
Input: "input",
Processors: []config.ProcessorConfig{
{Type: "string.create", Params: map[string]any{}},
},
Output: "output",
}
_, err := route.NewRoute(routeConfig)
if err == nil {
t.Fatalf("route error expected creating route with bad processor, got nil")
}
}

249
router.go
View File

@@ -3,72 +3,120 @@ package showbridge
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"sync" "sync"
"github.com/jwetzell/showbridge-go/internal/config" "github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/module" "github.com/jwetzell/showbridge-go/internal/module"
"github.com/jwetzell/showbridge-go/internal/route" "github.com/jwetzell/showbridge-go/internal/route"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
) )
type Router struct { type Router struct {
contextCancel context.CancelFunc contextCancel context.CancelFunc
Context context.Context Context context.Context
ModuleInstances []module.Module ModuleInstances map[string]module.Module
// TODO(jwetzell): change to something easier to lookup
RouteInstances []route.Route RouteInstances []route.Route
moduleWait sync.WaitGroup moduleWait sync.WaitGroup
logger *slog.Logger logger *slog.Logger
tracer trace.Tracer
runningConfig config.Config
} }
func NewRouter(ctx context.Context, config config.Config) (*Router, []module.ModuleError, []route.RouteError) { func (r *Router) addModule(moduleDecl config.ModuleConfig) error {
if moduleDecl.Id == "" {
return errors.New("module id cannot be empty")
}
moduleInfo, ok := module.ModuleRegistry[moduleDecl.Type]
if !ok {
return errors.New("module type not defined")
}
_, ok = r.ModuleInstances[moduleDecl.Id]
if ok {
return errors.New("module id already exists")
}
moduleInstance, err := moduleInfo.New(moduleDecl)
if err != nil {
return err
}
r.ModuleInstances[moduleDecl.Id] = moduleInstance
return nil
}
func (r *Router) removeModule(moduleId string) error {
err := r.stopModule(moduleId)
if err != nil {
return err
}
delete(r.ModuleInstances, moduleId)
return nil
}
func (r *Router) startModule(ctx context.Context, moduleId string) error {
moduleInstance := r.getModule(moduleId)
if moduleInstance == nil {
return errors.New("module id not found")
}
r.moduleWait.Go(func() {
err := moduleInstance.Start(ctx)
if err != nil {
// TODO(jwetzell): propagate module run errors better
r.logger.Error("error encountered running module", "moduleId", moduleId, "error", err)
}
})
return nil
}
func (r *Router) stopModule(moduleId string) error {
moduleInstance := r.getModule(moduleId)
if moduleInstance == nil {
return errors.New("module id not found")
}
moduleInstance.Stop()
return nil
}
// TODO(jwetzell): support removing route
func (r *Router) addRoute(routeDecl config.RouteConfig) error {
routeInstance, err := route.NewRoute(routeDecl)
if err != nil {
return err
}
r.RouteInstances = append(r.RouteInstances, routeInstance)
return nil
}
func (r *Router) getModule(moduleId string) module.Module {
moduleInstance, ok := r.ModuleInstances[moduleId]
if !ok {
return nil
}
return moduleInstance
}
func NewRouter(config config.Config, tracer trace.Tracer) (*Router, []module.ModuleError, []route.RouteError) {
routerContext, cancel := context.WithCancel(ctx)
router := Router{ router := Router{
Context: routerContext, ModuleInstances: make(map[string]module.Module),
contextCancel: cancel,
ModuleInstances: []module.Module{},
RouteInstances: []route.Route{}, RouteInstances: []route.Route{},
logger: slog.Default().With("component", "router"), logger: slog.Default().With("component", "router"),
tracer: tracer,
runningConfig: config,
} }
router.logger.Debug("creating")
router.logger.Debug("creating router")
var moduleErrors []module.ModuleError var moduleErrors []module.ModuleError
for moduleIndex, moduleDecl := range config.Modules { for moduleIndex, moduleDecl := range config.Modules {
moduleInfo, ok := module.ModuleRegistry[moduleDecl.Type] err := router.addModule(moduleDecl)
if !ok {
if moduleErrors == nil {
moduleErrors = []module.ModuleError{}
}
moduleErrors = append(moduleErrors, module.ModuleError{
Index: moduleIndex,
Config: moduleDecl,
Error: errors.New("module type not defined"),
})
continue
}
moduleInstanceExists := false
for _, moduleInstance := range router.ModuleInstances {
if moduleInstance.Id() == moduleDecl.Id {
moduleInstanceExists = true
if moduleErrors == nil {
moduleErrors = []module.ModuleError{}
}
moduleErrors = append(moduleErrors, module.ModuleError{
Index: moduleIndex,
Config: moduleDecl,
Error: errors.New("duplicate module id"),
})
break
}
}
if !moduleInstanceExists {
moduleInstance, err := moduleInfo.New(router.Context, moduleDecl, &router)
if err != nil { if err != nil {
if moduleErrors == nil { if moduleErrors == nil {
moduleErrors = []module.ModuleError{} moduleErrors = []module.ModuleError{}
@@ -81,14 +129,11 @@ func NewRouter(ctx context.Context, config config.Config) (*Router, []module.Mod
continue continue
} }
router.ModuleInstances = append(router.ModuleInstances, moduleInstance)
}
} }
var routeErrors []route.RouteError var routeErrors []route.RouteError
for routeIndex, routeDecl := range config.Routes { for routeIndex, routeDecl := range config.Routes {
routeInstance, err := route.NewRoute(routeDecl) err := router.addRoute(routeDecl)
if err != nil { if err != nil {
if routeErrors == nil { if routeErrors == nil {
routeErrors = []route.RouteError{} routeErrors = []route.RouteError{}
@@ -100,58 +145,114 @@ func NewRouter(ctx context.Context, config config.Config) (*Router, []module.Mod
}) })
continue continue
} }
router.RouteInstances = append(router.RouteInstances, routeInstance)
} }
return &router, moduleErrors, routeErrors return &router, moduleErrors, routeErrors
} }
func (r *Router) Run() { func (r *Router) Start(ctx context.Context) {
r.logger.Info("running router") r.logger.Info("running")
for _, moduleInstance := range r.ModuleInstances { routerContext, cancel := context.WithCancel(ctx)
r.moduleWait.Add(1) r.Context = routerContext
go func() { r.contextCancel = cancel
err := moduleInstance.Run() contextWithRouter := context.WithValue(routerContext, route.RouterContextKey, r)
if err != nil {
r.logger.Error("error encountered running module", "error", err) for moduleId := range r.ModuleInstances {
} // TODO(jwetzell): handle module run errors
r.moduleWait.Done() r.startModule(contextWithRouter, moduleId)
}()
} }
<-r.Context.Done() <-r.Context.Done()
r.logger.Debug("waiting for modules to exit")
r.moduleWait.Wait() r.moduleWait.Wait()
r.logger.Info("router done") r.logger.Info("done")
} }
func (r *Router) Stop() { func (r *Router) Stop() {
r.logger.Info("stopping")
r.contextCancel() r.contextCancel()
} }
func (r *Router) HandleInput(sourceId string, payload any) []route.RouteIOError { func (r *Router) HandleInput(ctx context.Context, sourceId string, payload any) (bool, []route.RouteIOError) {
var routingErrors []route.RouteIOError spanCtx, span := r.tracer.Start(ctx, "router.input", trace.WithAttributes(attribute.String("source.id", sourceId)), trace.WithNewRoot())
defer span.End()
var routeIOErrors []route.RouteIOError
routeFound := false
var routeWaitGroup sync.WaitGroup
for routeIndex, routeInstance := range r.RouteInstances { for routeIndex, routeInstance := range r.RouteInstances {
if routeInstance.Input() == sourceId { if routeInstance.Input() == sourceId {
err := routeInstance.HandleInput(r.Context, sourceId, payload, r) routeWaitGroup.Go(func() {
routeFound = true
routeContext := context.WithValue(spanCtx, route.SourceContextKey, sourceId)
routeCtx, routeSpan := r.tracer.Start(routeContext, "route", trace.WithAttributes(attribute.Int("route.index", routeIndex), attribute.String("route.input", routeInstance.Input()), attribute.String("route.output", routeInstance.Output())))
payload, err := routeInstance.ProcessPayload(routeCtx, payload)
if err != nil { if err != nil {
if routingErrors == nil { if routeIOErrors == nil {
routingErrors = []route.RouteIOError{} routeIOErrors = []route.RouteIOError{}
} }
routingErrors = append(routingErrors, route.RouteIOError{ r.logger.Error("unable to process input", "route", routeIndex, "source", sourceId, "error", err)
routeIOErrors = append(routeIOErrors, route.RouteIOError{
Index: routeIndex, Index: routeIndex,
Error: err, ProcessError: err,
}) })
r.logger.Error("router unable to route input", "route", routeIndex, "source", sourceId, "error", err) return
}
}
}
return routingErrors
} }
func (r *Router) HandleOutput(sourceId string, destinationId string, payload any) error { if payload == nil {
for _, moduleInstance := range r.ModuleInstances { r.logger.Error("no input after processing", "route", routeIndex, "source", sourceId)
if moduleInstance.Id() == destinationId { return
return moduleInstance.Output(payload) }
outputError := r.HandleOutput(routeCtx, routeInstance.Output(), payload)
if outputError != nil {
if routeIOErrors == nil {
routeIOErrors = []route.RouteIOError{}
}
routeIOErrors = append(routeIOErrors, route.RouteIOError{
Index: routeIndex,
OutputError: outputError,
})
}
routeSpan.End()
})
} }
} }
return fmt.Errorf("router could not find module instance for destination %s", destinationId) routeWaitGroup.Wait()
return routeFound, routeIOErrors
}
func (r *Router) HandleOutput(ctx context.Context, destinationId string, payload any) error {
spanCtx, span := r.tracer.Start(ctx, "router.output", trace.WithAttributes(attribute.String("destination.id", destinationId)))
defer span.End()
destinationModule := r.getModule(destinationId)
if destinationModule == nil {
err := errors.New("no module found for destination id")
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
r.logger.Error("no module found for destination id", "destinationId", destinationId)
return err
}
moduleOutputCtx, moduleOutputSpan := r.tracer.Start(spanCtx, "module.output", trace.WithAttributes(attribute.String("module.id", destinationModule.Id()), attribute.String("module.type", destinationModule.Type())))
defer moduleOutputSpan.End()
err := destinationModule.Output(moduleOutputCtx, payload)
if err != nil {
moduleOutputSpan.SetStatus(codes.Error, err.Error())
moduleOutputSpan.RecordError(err)
r.logger.Error("module output encountered error", "module", destinationModule.Id(), "error", err)
return err
} else {
moduleOutputSpan.SetStatus(codes.Ok, "module output successful")
}
return nil
}
func (r *Router) RunningConfig() config.Config {
return r.runningConfig
} }

376
router_test.go Normal file
View File

@@ -0,0 +1,376 @@
package showbridge_test
import (
"context"
"fmt"
"log/slog"
"sync"
"testing"
"time"
"github.com/jwetzell/showbridge-go"
"github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/module"
"github.com/jwetzell/showbridge-go/internal/route"
"go.opentelemetry.io/otel"
)
var (
tracer = otel.Tracer("showbridge.test")
)
type MockCounterModule struct {
config config.ModuleConfig
ctx context.Context
outputCount int
router route.RouteIO
logger *slog.Logger
cancel context.CancelFunc
}
func (mcm *MockCounterModule) Id() string {
return mcm.config.Id
}
func (mcm *MockCounterModule) Output(context.Context, any) error {
mcm.outputCount += 1
return nil
}
func (mcm *MockCounterModule) Start(ctx context.Context) error {
router, ok := ctx.Value(route.RouterContextKey).(route.RouteIO)
if !ok {
return fmt.Errorf("mock.counter could not get router from context")
}
mcm.router = router
moduleContext, cancel := context.WithCancel(ctx)
mcm.ctx = moduleContext
mcm.cancel = cancel
<-mcm.ctx.Done()
return nil
}
func (mcm *MockCounterModule) Type() string {
return mcm.config.Type
}
func (mcm *MockCounterModule) Stop() {
mcm.cancel()
}
func init() {
module.RegisterModule(module.ModuleRegistration{
Type: "mock.counter",
New: func(config config.ModuleConfig) (module.Module, error) {
return &MockCounterModule{config: config, logger: slog.Default()}, nil
},
})
}
func TestNewRouter(t *testing.T) {
routerConfig := config.Config{
Modules: []config.ModuleConfig{
{
Id: "mock",
Type: "mock.counter",
},
},
}
_, moduleErrors, routeErrors := showbridge.NewRouter(routerConfig, tracer)
if moduleErrors != nil {
t.Fatalf("router should not have returned any module errors: %v", moduleErrors)
}
if routeErrors != nil {
t.Fatalf("router should not have returned any route errors: %v", routeErrors)
}
}
func TestNewRouterNoModuleId(t *testing.T) {
routerConfig := config.Config{
Modules: []config.ModuleConfig{
{
Id: "",
Type: "mock.counter",
},
},
}
_, moduleErrors, _ := showbridge.NewRouter(routerConfig, tracer)
if moduleErrors == nil {
t.Fatalf("router should have returned 'unknown module' module errors")
}
}
func TestNewRouterUnknownModuleType(t *testing.T) {
routerConfig := config.Config{
Modules: []config.ModuleConfig{
{
Id: "mock",
Type: "asd.fjlkj23oiu4ksldj",
},
},
}
_, moduleErrors, _ := showbridge.NewRouter(routerConfig, tracer)
if moduleErrors == nil {
t.Fatalf("router should have returned 'unknown module' module errors")
}
}
func TestNewRouterDuplicateModuleId(t *testing.T) {
routerConfig := config.Config{
Modules: []config.ModuleConfig{
{
Id: "mock",
Type: "mock.counter",
},
{
Id: "mock",
Type: "mock.counter",
},
},
}
_, moduleErrors, _ := showbridge.NewRouter(routerConfig, tracer)
if moduleErrors == nil {
t.Fatalf("router should have returned 'duplicate id' module error")
}
}
func TestRouterInputSingleRoute(t *testing.T) {
routerConfig := config.Config{
Modules: []config.ModuleConfig{
{
Id: "mock",
Type: "mock.counter",
},
},
Routes: []config.RouteConfig{
{
Input: "mock",
Output: "mock",
},
},
}
router, moduleErrors, routeErrors := showbridge.NewRouter(routerConfig, tracer)
if moduleErrors != nil {
t.Fatalf("router should not have returned any module errors: %v", moduleErrors)
}
if routeErrors != nil {
t.Fatalf("router should not have returned any route errors: %v", routeErrors)
}
routerRunner := sync.WaitGroup{}
routerRunner.Go(func() {
router.Start(t.Context())
fmt.Println("router stopped")
})
time.Sleep(time.Second * 1)
defer router.Stop()
mockModuleInputCount := 3
for i := range mockModuleInputCount {
aRouteFound, routingErrors := router.HandleInput(t.Context(), "mock", fmt.Sprintf("test %d", i))
if routingErrors != nil {
t.Fatalf("router should not have encountered routing errors")
}
if !aRouteFound {
t.Fatalf("router should have found a valid route for the input")
}
}
for _, moduleInstance := range router.ModuleInstances {
if moduleInstance.Id() == "mock" {
mockModuleInstance, ok := moduleInstance.(*MockCounterModule)
if !ok {
t.Fatalf("couldn't get mock module")
}
if mockModuleInstance.outputCount != mockModuleInputCount {
t.Fatalf("mock module output count did not matched expected: %d got: %d", mockModuleInputCount, mockModuleInstance.outputCount)
}
}
}
}
func TestRouterInputMultipleRoutes(t *testing.T) {
routerConfig := config.Config{
Modules: []config.ModuleConfig{
{
Id: "mock",
Type: "mock.counter",
},
},
Routes: []config.RouteConfig{
{
Input: "mock",
Output: "mock",
},
{
Input: "mock",
Output: "mock",
},
{
Input: "mock",
Output: "mock",
},
},
}
router, moduleErrors, routeErrors := showbridge.NewRouter(routerConfig, tracer)
if moduleErrors != nil {
t.Fatalf("router should not have returned any module errors: %v", moduleErrors)
}
if routeErrors != nil {
t.Fatalf("router should not have returned any route errors: %v", routeErrors)
}
routerRunner := sync.WaitGroup{}
routerRunner.Go(func() {
router.Start(t.Context())
})
time.Sleep(time.Second * 1)
defer router.Stop()
mockModuleInputCount := 3
for i := range mockModuleInputCount {
aRouteFound, routingErrors := router.HandleInput(t.Context(), "mock", fmt.Sprintf("test %d", i))
if routingErrors != nil {
t.Fatalf("router should not have encountered routing errors")
}
if !aRouteFound {
t.Fatalf("router should have found a valid route for the input")
}
}
for _, moduleInstance := range router.ModuleInstances {
if moduleInstance.Id() == "mock" {
mockModuleInstance, ok := moduleInstance.(*MockCounterModule)
if !ok {
t.Fatalf("couldn't get mock module")
}
if mockModuleInstance.outputCount != len(router.RouteInstances)*mockModuleInputCount {
t.Fatalf("mock module output count did not matched expected: %d got: %d", len(router.RouteInstances)*mockModuleInputCount, mockModuleInstance.outputCount)
}
break
}
}
}
func TestRouterInputMultipleModules(t *testing.T) {
routerConfig := config.Config{
Modules: []config.ModuleConfig{
{
Id: "mock1",
Type: "mock.counter",
},
{
Id: "mock2",
Type: "mock.counter",
},
},
Routes: []config.RouteConfig{
{
Input: "mock1",
Output: "mock1",
},
{
Input: "mock2",
Output: "mock2",
},
},
}
router, moduleErrors, routeErrors := showbridge.NewRouter(routerConfig, tracer)
if moduleErrors != nil {
t.Fatalf("router should not have returned any module errors: %v", moduleErrors)
}
if routeErrors != nil {
t.Fatalf("router should not have returned any route errors: %v", routeErrors)
}
routerRunner := sync.WaitGroup{}
routerRunner.Go(func() {
router.Start(t.Context())
})
time.Sleep(time.Second * 1)
defer router.Stop()
mock1ModuleInputCount := 3
for i := range mock1ModuleInputCount {
aRouteFound, routingErrors := router.HandleInput(t.Context(), "mock1", fmt.Sprintf("test %d", i))
if routingErrors != nil {
t.Fatalf("router should not have encountered routing errors")
}
if !aRouteFound {
t.Fatalf("router should have found a valid route for the input")
}
}
mock2ModuleInputCount := 2
for i := range mock2ModuleInputCount {
aRouteFound, routingErrors := router.HandleInput(t.Context(), "mock2", fmt.Sprintf("test %d", i))
if routingErrors != nil {
t.Fatalf("router should not have encountered routing errors")
}
if !aRouteFound {
t.Fatalf("router should have found a valid route for the input")
}
}
for _, moduleInstance := range router.ModuleInstances {
if moduleInstance.Id() == "mock1" {
mockModuleInstance, ok := moduleInstance.(*MockCounterModule)
if !ok {
t.Fatalf("couldn't get mock module")
}
if mockModuleInstance.outputCount != mock1ModuleInputCount {
t.Fatalf("mock module output count did not matched expected: %d got: %d", mock1ModuleInputCount, mockModuleInstance.outputCount)
}
break
}
if moduleInstance.Id() == "mock2" {
mockModuleInstance, ok := moduleInstance.(*MockCounterModule)
if !ok {
t.Fatalf("couldn't get mock module")
}
if mockModuleInstance.outputCount != mock2ModuleInputCount {
t.Fatalf("mock module output count did not matched expected: %d got: %d", mock2ModuleInputCount, mockModuleInstance.outputCount)
}
break
}
}
}

15
schema/config.schema.json Normal file
View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://showbridge.io/config.schema.json",
"title": "Config",
"description": "showbridge config file",
"type": "object",
"properties": {
"modules": {
"$ref": "https://showbridge.io/modules.schema.json"
},
"routes": {
"$ref": "https://showbridge.io/routes.schema.json"
}
}
}

481
schema/modules.schema.json Normal file
View File

@@ -0,0 +1,481 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://showbridge.io/modules.schema.json",
"title": "Modules",
"description": "showbridge modules array",
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"title": "HTTPClientModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "http.client"
}
},
"required": ["id", "type"],
"additionalProperties": false
},
{
"type": "object",
"title": "HTTPServerModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "http.server"
},
"params": {
"type": "object",
"properties": {
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
},
"required": ["port"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "TimeIntervalModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "time.interval"
},
"params": {
"type": "object",
"properties": {
"duration": {
"type": "integer"
}
},
"required": ["duration"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "TimeTimerModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "time.timer"
},
"params": {
"type": "object",
"properties": {
"duration": {
"type": "integer"
}
},
"required": ["duration"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "MIDIInputModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "midi.input"
},
"params": {
"type": "object",
"properties": {
"port": {
"type": "string"
}
},
"required": ["port"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "MIDIOutputModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "midi.output"
},
"params": {
"type": "object",
"properties": {
"port": {
"type": "string"
}
},
"required": ["port"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "MQTTClientModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "mqtt.client"
},
"params": {
"type": "object",
"properties": {
"broker": {
"type": "string"
},
"topic": {
"type": "string"
},
"clientId": {
"type": "string"
}
},
"required": ["broker", "topic", "clientId"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "NATSClientModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "nats.client"
},
"params": {
"type": "object",
"properties": {
"url": {
"type": "string"
},
"subject": {
"type": "string"
}
},
"required": ["url", "subject"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "PSNClientModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "psn.client"
}
},
"required": ["id", "type"],
"additionalProperties": false
},
{
"type": "object",
"title": "SerialClientModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "serial.client"
},
"params": {
"type": "object",
"properties": {
"port": {
"type": "string"
},
"baudRate": {
"type": "integer"
}
},
"required": ["port", "baudRate"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "SIPCallServerModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "sip.call.server"
},
"params": {
"type": "object",
"properties": {
"ip": {
"type": "string",
"default": "0.0.0.0"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 5060
},
"transport": {
"type": "string",
"enum": ["udp", "tcp", "ws", "udp4", "tcp4"],
"default": "udp"
},
"userAgent": {
"type": "string",
"default": "showbridge"
}
},
"required": [],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "SIPDTMFServerModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "sip.dtmf.server"
},
"params": {
"type": "object",
"properties": {
"ip": {
"type": "string",
"default": "0.0.0.0"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 5060
},
"transport": {
"type": "string",
"enum": ["udp", "tcp", "ws", "udp4", "tcp4"],
"default": "udp"
},
"separator": {
"type": "string",
"minLength": 1,
"maxLength": 1
}
},
"required": ["separator"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "TCPClientModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "net.tcp.client"
},
"params": {
"type": "object",
"properties": {
"host": {
"type": "string"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"framing": {
"type": "string",
"enum": ["LF", "CR", "CRLF", "SLIP", "RAW"]
}
},
"required": ["host", "port", "framing"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "TCPServerModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "net.tcp.server"
},
"params": {
"type": "object",
"properties": {
"ip": {
"type": "string",
"default": "0.0.0.0"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"framing": {
"type": "string",
"enum": ["LF", "CR", "CRLF", "SLIP", "RAW"]
}
},
"required": ["port", "framing"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "UDPClientModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "net.udp.client"
},
"params": {
"type": "object",
"properties": {
"host": {
"type": "string"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
},
"required": ["host", "port"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "UDPMulticastModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "net.udp.multicast"
},
"params": {
"type": "object",
"properties": {
"ip": {
"type": "string"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
},
"required": ["ip", "port"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
},
{
"type": "object",
"title": "UDPServerModule",
"properties": {
"id": {
"type": "string"
},
"type": {
"const": "net.udp.server"
},
"params": {
"type": "object",
"properties": {
"ip": {
"type": "string",
"default": "0.0.0.0"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
},
"required": ["port"],
"additionalProperties": false
}
},
"required": ["id", "type", "params"],
"additionalProperties": false
}
]
}
}

View File

@@ -0,0 +1,806 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://showbridge.io/processors.schema.json",
"title": "Processors",
"description": "showbridge processors array",
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "artnet.packet.decode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "artnet.packet.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "artnet.packet.filter"
},
"params": {
"type": "object",
"properties": {
"opCode": {
"type": "integer"
}
},
"required": ["opCode"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "debug.log"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "float.parse"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "freed.create"
},
"params": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"pan": {
"type": "string"
},
"tilt": {
"type": "string"
},
"roll": {
"type": "string"
},
"posX": {
"type": "string"
},
"posY": {
"type": "string"
},
"posZ": {
"type": "string"
},
"zoom": {
"type": "string"
},
"focus": {
"type": "string"
}
},
"required": [
"id",
"pan",
"tilt",
"roll",
"posX",
"posY",
"posZ",
"zoom",
"focus"
],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "freed.decode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "freed.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "http.request.create"
},
"params": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST"]
},
"url": {
"type": "string"
}
},
"required": ["method", "url"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "http.request.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "http.request.filter"
},
"params": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"method": {
"type": "string",
"enum": ["GET", "POST"]
}
},
"required": ["path"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "http.response.create"
},
"params": {
"type": "object",
"properties": {
"status": {
"type": "integer"
},
"body": {
"type": "string"
}
},
"required": ["status", "body"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "http.response.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "int.parse"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "int.random"
},
"params": {
"type": "object",
"properties": {
"min": {
"type": "integer"
},
"max": {
"type": "integer"
}
},
"required": ["min", "max"],
"additionalProperties": false
}
}
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "json.decode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "json.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "midi.message.create"
},
"params": {
"type": "object",
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["NoteOn", "noteon", "note_on"]
},
"channel": {
"type": "string"
},
"note": {
"type": "string"
},
"velocity": {
"type": "string"
}
},
"required": ["type", "channel", "note", "velocity"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["NoteOff", "noteoff", "note_off"]
},
"channel": {
"type": "string"
},
"note": {
"type": "string"
},
"velocity": {
"type": "string"
}
},
"required": ["type", "channel", "note", "velocity"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["ControlChange", "controlchange", "control_change"]
},
"channel": {
"type": "string"
},
"controller": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": ["type", "channel", "controller", "value"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["ProgramChange", "programchange", "program_change"]
},
"channel": {
"type": "string"
},
"program": {
"type": "string"
}
},
"required": ["type", "channel", "program"],
"additionalProperties": false
}
]
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "midi.message.decode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "midi.message.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "midi.message.filter"
},
"params": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["NoteOn", "NoteOff", "ControlChange", "ProgramChange"]
}
},
"required": ["type"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "midi.message.unpack"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mqtt.message.create"
},
"params": {
"type": "object",
"properties": {
"topic": {
"type": "string"
},
"qos": {
"type": "number"
},
"retained": {
"type": "boolean"
},
"payload": {
"type": "string"
}
},
"required": ["topic", "qos", "retained", "payload"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mqtt.message.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "nats.message.create"
},
"params": {
"type": "object",
"properties": {
"subject": {
"type": "string"
},
"payload": {
"type": "string"
}
},
"required": ["subject", "payload"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "nats.message.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "osc.message.create"
},
"params": {
"type": "object",
"properties": {
"address": {
"type": "string"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
},
"types": {
"type": "string"
}
},
"required": ["address"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "osc.message.decode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "osc.message.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "osc.message.filter"
},
"params": {
"type": "object",
"properties": {
"address": {
"type": "string"
}
},
"required": ["address"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "script.expr"
},
"params": {
"type": "object",
"properties": {
"expression": {
"type": "string"
}
},
"required": ["expression"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "script.js"
},
"params": {
"type": "object",
"properties": {
"program": {
"type": "string"
}
},
"required": ["program"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "script.wasm"
},
"params": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"function": {
"type": "string",
"default": "process"
}
},
"required": ["path"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "string.create"
},
"params": {
"type": "object",
"properties": {
"template": {
"type": "string"
}
},
"required": ["template"]
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "string.decode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "string.encode"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "string.filter"
},
"params": {
"type": "object",
"properties": {
"pattern": {
"type": "string"
}
},
"required": ["pattern"],
"additionalProperties": false
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "string.split"
},
"params": {
"type": "object",
"properties": {
"separator": {
"type": "string"
}
},
"required": ["separator"]
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "time.sleep"
},
"params": {
"type": "object",
"properties": {
"duration": {
"type": "integer"
}
},
"required": ["duration"]
}
},
"required": ["type", "params"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "uint.parse"
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "uint.random"
},
"params": {
"type": "object",
"properties": {
"min": {
"type": "integer",
"minimum": 0
},
"max": {
"type": "integer",
"minimum": 0
}
},
"required": ["min", "max"],
"additionalProperties": false
}
}
}
]
}
}

22
schema/routes.schema.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://showbridge.io/routes.schema.json",
"title": "Routes",
"description": "showbridge routes array",
"type": "array",
"items": {
"type": "object",
"properties": {
"input": {
"type": "string"
},
"processors": {
"$ref": "https://showbridge.io/processors.schema.json"
},
"output": {
"type": "string"
}
},
"required": ["input", "output"]
}
}