diff --git a/client/Pipfile b/client/Pipfile index 48342a0..1fab8a1 100644 --- a/client/Pipfile +++ b/client/Pipfile @@ -6,18 +6,17 @@ name = "pypi" [packages] websockets = "*" mypy = "*" -coloredlogs = "*" pylint = "*" result = "*" click = "*" requests = "*" rich = "*" pyserial = "*" -kivy = { version = "*", platform_machine = "!= 'aarch64'" } bitstring = "*" pyyaml = "*" appdirs = "*" aiohttp = "*" +textual = "*" [dev-packages] pyinstaller = "*" diff --git a/client/Pipfile.lock b/client/Pipfile.lock index 2ff7f57..36498ba 100644 --- a/client/Pipfile.lock +++ b/client/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "9a19e4ba4ddb54dd5a969c63461ccefceb2a2204f5f313179d3348b50e4a9b82" + "sha256": "a9b9ec1c5f3c146f5eef8e671204dfec1f39cca09d56e96805d82d624f3185a6" }, "pipfile-spec": 6, "requires": { @@ -155,7 +155,7 @@ "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" ], - "markers": "python_full_version >= '3.5.0'", + "markers": "python_version >= '3'", "version": "==2.0.12" }, "click": { @@ -166,22 +166,6 @@ "index": "pypi", "version": "==8.0.4" }, - "colorama": { - "hashes": [ - "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", - "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.4.4" - }, - "coloredlogs": { - "hashes": [ - "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", - "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" - ], - "index": "pypi", - "version": "==15.0.1" - }, "commonmark": { "hashes": [ "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", @@ -189,14 +173,6 @@ ], "version": "==0.9.1" }, - "docutils": { - "hashes": [ - "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c", - "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.18.1" - }, "frozenlist": { "hashes": [ "sha256:006d3595e7d4108a12025ddf415ae0f6c9e736e726a5db0183326fd191b14c5e", @@ -262,14 +238,6 @@ "markers": "python_version >= '3.7'", "version": "==1.3.0" }, - "humanfriendly": { - "hashes": [ - "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", - "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==10.0" - }, "idna": { "hashes": [ "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", @@ -283,40 +251,9 @@ "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7", "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951" ], - "markers": "python_version < '4' and python_full_version >= '3.6.1'", + "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", "version": "==5.10.1" }, - "kivy": { - "hashes": [ - "sha256:23a7538f9c02e5d7f82f0b92d7fd313fa21ee8f0bd9890283fc7e7b02090f101", - "sha256:256846daa1a35b54b00426f7468423a962c3d8f909d7e8c713acab55c3281dee", - "sha256:3e739c04d3a2f38cb76779d721487131cff8ed84dbc1730d5025d62306bf6e52", - "sha256:5bd7dba6b0bc1f71623fa734fd63e38038772336995a334718cf8474a877eb40", - "sha256:7d736474c8075d6ee17203bd5bd42d74307239409442b27d5bcb3f3641ab0414", - "sha256:802982bbc7ff45bc2fa8af3816008252a3e63ded949a9f5ed6d361ecb3cfc2b7", - "sha256:86cf1f0e40ef411872c9dbc75fc1e17ec6579d1e55cafe286da6c67c07dba4cb", - "sha256:8973ed9f0cb0d9ef0f3a520841c61c0093788a7382e8ebe7c108ec03766d9fef", - "sha256:98ed5f46f05707a80e32a4fdd7e9fce4ac5f15da8fc83f91d5769dc66f137e20", - "sha256:a4a693d6d1fc26928498512b9da797581e407543a644b4e0c92b9297ea2fbec9", - "sha256:aae542f2c030d4d95f5a717b7c862cd9537b74b99010b08807db63fdd9b029e0", - "sha256:bfb6b801599eac5aa9388308119d42a637a495945c79e2e2a3f6ef60c563c770", - "sha256:d25e44eb44e43762b2fd0c5874e51954e0f1181fd9800d8a6756be6d084812d8", - "sha256:eb1cc4c1e223e290d413a383ac864d29d098b8abba8e881159c7d1177c6579af", - "sha256:eca7bde37a2cffffdcdde85ac385c2e55d776333db00be0497bf884f61022d24", - "sha256:ed1c3076d9ef1171f6076b3b97215e5c0bcea831ca308722677611a551f16b1b", - "sha256:ef3727a47a565e6ea486365ee310bfb0445fcf7f0ab40c1461cbf304973637eb" - ], - "index": "pypi", - "markers": "platform_machine != 'aarch64'", - "version": "==2.0.0" - }, - "kivy-garden": { - "hashes": [ - "sha256:9b7d9de5efacbcd0c4b3dd873b30622a86093c9965aa47b523c7a32f3eb34610", - "sha256:c256f42788421273a08fbb0a228f0fb0e80dd86b629fb8c0920507f645be6c72" - ], - "version": "==0.1.4" - }, "lazy-object-proxy": { "hashes": [ "sha256:043651b6cb706eee4f91854da4a089816a6606c1428fd391573ef8cb642ae4f7", @@ -434,29 +371,32 @@ }, "mypy": { "hashes": [ - "sha256:0038b21890867793581e4cb0d810829f5fd4441aa75796b53033af3aa30430ce", - "sha256:1171f2e0859cfff2d366da2c7092b06130f232c636a3f7301e3feb8b41f6377d", - "sha256:1b06268df7eb53a8feea99cbfff77a6e2b205e70bf31743e786678ef87ee8069", - "sha256:1b65714dc296a7991000b6ee59a35b3f550e0073411ac9d3202f6516621ba66c", - "sha256:1bf752559797c897cdd2c65f7b60c2b6969ffe458417b8d947b8340cc9cec08d", - "sha256:300717a07ad09525401a508ef5d105e6b56646f7942eb92715a1c8d610149714", - "sha256:3c5b42d0815e15518b1f0990cff7a705805961613e701db60387e6fb663fe78a", - "sha256:4365c60266b95a3f216a3047f1d8e3f895da6c7402e9e1ddfab96393122cc58d", - "sha256:50c7346a46dc76a4ed88f3277d4959de8a2bd0a0fa47fa87a4cde36fe247ac05", - "sha256:5b56154f8c09427bae082b32275a21f500b24d93c88d69a5e82f3978018a0266", - "sha256:74f7eccbfd436abe9c352ad9fb65872cc0f1f0a868e9d9c44db0893440f0c697", - "sha256:7b3f6f557ba4afc7f2ce6d3215d5db279bcf120b3cfd0add20a5d4f4abdae5bc", - "sha256:8c11003aaeaf7cc2d0f1bc101c1cc9454ec4cc9cb825aef3cafff8a5fdf4c799", - "sha256:8ca7f8c4b1584d63c9a0f827c37ba7a47226c19a23a753d52e5b5eddb201afcd", - "sha256:c89702cac5b302f0c5d33b172d2b55b5df2bede3344a2fbed99ff96bddb2cf00", - "sha256:d8f1ff62f7a879c9fe5917b3f9eb93a79b78aad47b533911b853a757223f72e7", - "sha256:d9d2b84b2007cea426e327d2483238f040c49405a6bf4074f605f0156c91a47a", - "sha256:e839191b8da5b4e5d805f940537efcaa13ea5dd98418f06dc585d2891d228cf0", - "sha256:f9fe20d0872b26c4bba1c1be02c5340de1019530302cf2dcc85c7f9fc3252ae0", - "sha256:ff3bf387c14c805ab1388185dd22d6b210824e164d4bb324b195ff34e322d166" + "sha256:0b52778a018559a256c819ee31b2e21e10b31ddca8705624317253d6d08dbc35", + "sha256:0fdc9191a49c77ab5fa0439915d405e80a1118b163ab03cd2a530f346b12566a", + "sha256:13677cb8b050f03b5bb2e8bf7b2668cd918b001d56c2435082bbfc9d5f730f42", + "sha256:1903c92ff8642d521b4627e51a67e49f5be5aedb1fb03465b3aae4c3338ec491", + "sha256:1f66f2309cdbb07e95e60e83fb4a8272095bd4ea6ee58bf9a70d5fb304ec3e3f", + "sha256:2dba92f58610d116f68ec1221fb2de2a346d081d17b24a784624389b17a4b3f9", + "sha256:2efd76893fb8327eca7e942e21b373e6f3c5c083ff860fb1e82ddd0462d662bd", + "sha256:3ac14949677ae9cb1adc498c423b194ad4d25b13322f6fe889fb72b664c79121", + "sha256:471af97c35a32061883b0f8a3305ac17947fd42ce962ca9e2b0639eb9141492f", + "sha256:51be997c1922e2b7be514a5215d1e1799a40832c0a0dee325ba8794f2c48818f", + "sha256:628f5513268ebbc563750af672ccba5eef7f92d2d90154233edd498dfb98ca4e", + "sha256:68038d514ae59d5b2f326be502a359160158d886bd153fc2489dbf7a03c44c96", + "sha256:6eab2bcc2b9489b7df87d7c20743b66d13254ad4d6430e1dfe1a655d51f0933d", + "sha256:712affcc456de637e774448c73e21c84dfa5a70bcda34e9b0be4fb898a9e8e07", + "sha256:71bec3d2782d0b1fecef7b1c436253544d81c1c0e9ca58190aed9befd8f081c5", + "sha256:83f66190e3c32603217105913fbfe0a3ef154ab6bbc7ef2c989f5b2957b55840", + "sha256:8aaf18d0f8bc3ffba56d32a85971dfbd371a5be5036da41ac16aefec440eff17", + "sha256:a0e5657ccaedeb5fdfda59918cc98fc6d8a8e83041bc0cec347a2ab6915f9998", + "sha256:a168da06eccf51875fdff5f305a47f021f23f300e2b89768abdac24538b1f8ec", + "sha256:b1a116c451b41e35afc09618f454b5c2704ba7a4e36f9ff65014fef26bb6075b", + "sha256:b2fa5f2d597478ccfe1f274f8da2f50ea1e63da5a7ae2342c5b3b2f3e57ec340", + "sha256:d9d7647505bf427bc7931e8baf6cacf9be97e78a397724511f20ddec2a850752", + "sha256:f8fe1bfab792e4300f80013edaf9949b34e4c056a7b2531b5ef3a0fb9d598ae2" ], "index": "pypi", - "version": "==0.931" + "version": "==0.940" }, "mypy-extensions": { "hashes": [ @@ -478,7 +418,7 @@ "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65", "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a" ], - "markers": "python_full_version >= '3.5.0'", + "markers": "python_version >= '3.5'", "version": "==2.11.2" }, "pylint": { @@ -554,11 +494,11 @@ }, "rich": { "hashes": [ - "sha256:1a6266a5738115017bb64a66c59c717e7aa047b3ae49a011ede4abdeffc6536e", - "sha256:d5f49ad91fb343efcae45a2b2df04a9755e863e50413623ab8c9e74f05aee52b" + "sha256:14bfd0507edc633e021b02c45cbf7ca22e33b513817627b8de3412f047a3e798", + "sha256:fdcd2f8d416e152bcf35c659987038d1ae5a7bd336e821ca7551858a4c7e38a9" ], "index": "pypi", - "version": "==11.2.0" + "version": "==12.0.0" }, "setuptools": { "hashes": [ @@ -568,6 +508,14 @@ "markers": "python_version >= '3.7'", "version": "==60.9.3" }, + "textual": { + "hashes": [ + "sha256:12c3e7d77faa76463e40e4eff58d591143770bb61780ecf0d2dae65e783896cd", + "sha256:af6aa1fa34fe6d40689ce55a7bf519a07e48523a75b95cbed572990e0d6b6f84" + ], + "index": "pypi", + "version": "==0.1.17" + }, "toml": { "hashes": [ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", @@ -589,7 +537,7 @@ "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42", "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2" ], - "markers": "python_version >= '3.6'", + "markers": "python_version < '3.10'", "version": "==4.1.1" }, "urllib3": { @@ -597,7 +545,7 @@ "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4.0'", "version": "==1.26.8" }, "websockets": { @@ -800,20 +748,20 @@ }, "pyinstaller": { "hashes": [ - "sha256:24035eb9fffa2e3e288b4c1c9710043819efc7203cae5c8c573bec16f4a8e98f", - "sha256:59372b950d176fdc5ecea29719a8ab3f194b73a15b7f9875ac2a1de9a3daf5ed", - "sha256:62c97cbbdbee30974d607eb1de9afb081eb3adba787c203b00438e21027b829b", - "sha256:75a180a658871bc41f9cf94b6f90ffa54e98f5d6a7cdb02d7530f0360afe24f9", - "sha256:7f46ab11ec986e4c525b93251063144e12d432a132dbc0070e3030e34c76537a", - "sha256:a0b988cfc197d40e3d773b3aa1c7d3e918fc0933b4c15ec3fc5d156f222d82cb", - "sha256:b5f1a94150315ea75bf3501be6c8476d65a7209580bb662da06dbdbc4454f375", - "sha256:bec57b3b2b6178907255557ec0fc4b5ce5a0474013414cdadea853205c74ed26", - "sha256:e2f165cea4470ce8a8349112cd78f48a61413805adc17792a91997a11cfe1d80", - "sha256:ebeb87cdbadb2b4e8f991ffd9945ebd4fb3a7303180e63682c3e1ce01b3fdd22", - "sha256:ec3ca331d565ffca1b6470c5aaf798885a03708c3d0b15c1b19009126f84c1d4" + "sha256:05c21117b84199272ebd355b556af4714f6e79245e1c435d6f16653786d7d17e", + "sha256:0dcaf6557cdb2da763c46e06e95a94a7634ab03fb09d91bc77988b01ee05c907", + "sha256:15557cd1a79d182967f0a5040750e6902e13ebd6cab41e3ed84d7b28a306357b", + "sha256:581620bdcd32f01e89b13231256b807bb090e7eadf40c81c864ec402afa4758a", + "sha256:70c71e827f4b34602cbc7a0947a067b662c1cbdc4db51832e13b97cca3c54dd7", + "sha256:714c4dcc319a41416744d1e30c6317405dfaed80d2adc45f8bfa70dc7367e664", + "sha256:7749c868d2e2dc84df7d6f65437226183c8a366f3a99bb2737785625c3a3cca1", + "sha256:7d94518ba1f8e9a8577345312276891ad7d6cd9785e453e9951b35647e2c7078", + "sha256:cfed0b3a43e73550a43a094610328109564710b9514afa093ef7199d072cae87", + "sha256:d4f79c0a774451f12baca4e476376418f011fa3039dde8fd172ea2aa8ff67bad", + "sha256:f2166ff2cd95eefb0d377ae8d1071f186fa25edd410ede65b376162d5ec41909" ], "index": "pypi", - "version": "==4.9" + "version": "==4.10" }, "pyinstaller-hooks-contrib": { "hashes": [ diff --git a/client/README.md b/client/README.md index 438dbb5..96143c2 100644 --- a/client/README.md +++ b/client/README.md @@ -16,15 +16,15 @@ - Run `./dist/dip_client --help` to print built client CLI usage definition ### File tree -- `dip_client.py` is just an entrypoint for the CLI interface from `cli.py` -- `cli.py` defines a CLI interface for all possible DIP client commands: - - Generic one-off client commands are defined in `backend_util.py` - - Microcontroller agent commands are defined in `agent_entrypoints.py` +- `main.py` is just an entrypoint for the CLI interface from `service/click.py` +- `service/click.py` and `service/cli.py` define a CLI interface for all possible DIP client commands: + - Generic one-off client commands are defined mostly in `service/backend.py` + - Persistent event engine agent commands are defined in `agent/*`, `monitor/*`, `engine/*` - `agent_entrypoints.py` prepare and run a configured agent `agent.py` -- `agent.py` runs using an `AgentConfig`, `Engine`, serialization `Encoder`/`Decoder` -- `agent_util.py` defines a base `AgentConfig` -- `engine.py` defines a base `Engine` -- `agent_*.py` define custom `Engine`s and `AgentConfig`s +- `agent/agent.py` runs using an `AgentConfig` i.e. an `Engine` attached to `SocketInterface` with the help of message `Codec`s +- `engine/engine.py` defines a base `Engine` which starts, stops, receives messages, processes events, executes side-effects +- `engine/board/*` define engines to handle hardware board lifecycle - heartbeats, firmware uploads, monitoring +- `engine/video/*` define video streaming engines using VLC +- `monitor/*` define serial monitoring interfaces - Agents use `ws.py` to exchange WebSocket messages -- Agents use `s11n_*.py` to serialize messages -- Agents use `Engine` for stateful actions & resulting messages +- Agents more specifically SocketInterfaces use `protocol/*` to encode/decode messages diff --git a/client/build_docker.sh b/client/build_docker.sh index 47d120c..4d497ba 100755 --- a/client/build_docker.sh +++ b/client/build_docker.sh @@ -11,13 +11,9 @@ cd "${SCRIPT_DIR}" rm -rf docker/ rm -rf dist/ -# Build amd64 locally (supposing that we're building on amd64) -pipenv install && pipenv run ./build.sh & -LOCAL_INSTALL_PID="$!" - ## Build amd64 & arm64 in docker w/ buildx i.e. moby/buildkit i.e. QEMU ## Multi-platform docs: https://github.com/moby/buildkit/blob/master/docs/multi-platform.md -export TARGET_PLATFORMS=linux/arm64 #,linux/amd64 # Currently Kivy (used in amd64) doesn't comply with docker builds +export TARGET_PLATFORMS=linux/arm64,linux/amd64 docker run --rm --privileged multiarch/qemu-user-static --reset -p yes docker buildx create --name multiarch --driver docker-container --use || true docker buildx build \ @@ -25,10 +21,7 @@ docker buildx build \ -o type=local,dest=docker \ . -# Wait for local stuff to finish -wait "${LOCAL_INSTALL_PID}" - -# Clean up dist -mv dist/dip_client dist/dip_client_amd64 -#mv docker/linux_arm64/app/dist/dip_client dist/dip_client_arm64 -mv docker/app/dist/dip_client dist/dip_client_arm64 +# Create dist +mkdir dist +mv docker/linux_arm64/app/dist/dip_client dist/dip_client_arm64 +mv docker/linux_amd64/app/dist/dip_client dist/dip_client_amd64 diff --git a/client/src/domain/fancy_byte.py b/client/src/domain/fancy_byte.py new file mode 100644 index 0000000..580a645 --- /dev/null +++ b/client/src/domain/fancy_byte.py @@ -0,0 +1,36 @@ +import math +from dataclasses import dataclass +from typing import Iterable +from result import Result, Err, Ok + + +@dataclass +class FancyByte: + value: int + + @staticmethod + def fromBytes(b: bytes) -> Result['FancyByte', str]: + if len(b) > 1: + return Err("Only one byte allowed") + try: + return Ok(FancyByte(int.from_bytes(b, "little"))) + except Exception as e: + return Err(f"Can't build byte: {e}") + + @staticmethod + def fromInt(n: int) -> Result['FancyByte', str]: + if n < 0: + return Err("Byte too small") + elif n >= math.pow(2, 8): + return Err("Byte too large") + return Ok(FancyByte(n)) + + def to_binary_bits(self) -> Iterable[bool]: + str_bits_8 = bin(self.value)[2:].zfill(8) + return list(map(lambda x: x == "1", str_bits_8)) + + def to_hex_str(self) -> str: + return str(hex(self.value)) + + def to_char(self) -> str: + return chr(self.value) diff --git a/client/src/domain/fancy_byte_test.py b/client/src/domain/fancy_byte_test.py new file mode 100644 index 0000000..2bf2055 --- /dev/null +++ b/client/src/domain/fancy_byte_test.py @@ -0,0 +1,33 @@ +import math +import unittest + +from result import Ok, Err + +from src.domain.fancy_byte import FancyByte + + +class TestFancyByte(unittest.TestCase): + def test_fancy_int_bytes(self): + io = [ + (-1, Err("Byte too small")), + (math.pow(2, 8), Err("Byte too large")), + (0, Ok([False, False, False, False, False, False, False, False])), + (1, Ok([False, False, False, False, False, False, False, True])), + ] + for (input, output) in io: + reality = FancyByte.fromInt(input).map(lambda x: x.to_binary_bits()) + self.assertEqual(reality, output, input) + + def test_fancy_byte_bytes(self): + io = [ + ((0).to_bytes(1, byteorder='big'), Ok([False, False, False, False, False, False, False, False])), + ((1).to_bytes(1, byteorder='big'), Ok([False, False, False, False, False, False, False, True])), + ((int(math.pow(2, 8) - 1)).to_bytes(1, byteorder='big'), Ok([True, True, True, True, True, True, True, True])), + ] + for (input, output) in io: + reality = FancyByte.fromBytes(input).map(lambda x: x.to_binary_bits()) + self.assertEqual(reality, output, input) + + +if __name__ == '__main__': + unittest.main() diff --git a/client/src/hook-cli.py b/client/src/hook-cli.py deleted file mode 100644 index b58ee40..0000000 --- a/client/src/hook-cli.py +++ /dev/null @@ -1,16 +0,0 @@ -"""A hint for pyinstaller to pack additional hidden imports/libraries""" -from src.monitor import monitor_serial_button_led_bytes_app - -is_kivy_available = monitor_serial_button_led_bytes_app.is_kivy_available() - -kivy_imports = [ - "kivy", - "kivy.app.*", - "kivy.uix.button.*", - "kivy.uix.gridlayout.*", - "kivy.uix.label.*", - "kivy.core.window.*", - "kivy.properties.*", -] - -hiddenimports = kivy_imports if is_kivy_available else [] diff --git a/client/src/monitor/monitor_serial_button_led_bytes.py b/client/src/monitor/monitor_serial_button_led_bytes.py index 807a4b5..ee80741 100644 --- a/client/src/monitor/monitor_serial_button_led_bytes.py +++ b/client/src/monitor/monitor_serial_button_led_bytes.py @@ -1,23 +1,23 @@ """Module for functionality related to serial socket monitor as button/led byte stream""" import asyncio -from typing import Any, Callable, Optional +from typing import Callable, Optional import signal from pprint import pformat -from result import Ok, Err, Result +from result import Err, Result from websockets.exceptions import ConnectionClosedError - from src.domain.dip_client_error import DIPClientError, GenericClientError +from src.domain.fancy_byte import FancyByte from src.domain.monitor_message import MONITOR_LISTENER_INCOMING_MESSAGE, SerialMonitorMessageToClient, \ MonitorUnavailable, SerialMonitorMessageToAgent, MONITOR_LISTENER_OUTGOING_MESSAGE from src.monitor.monitor_serial import MonitorSerial -from src.monitor.monitor_serial_button_led_bytes_app import AppState, define_app +from src.monitor.monitor_serial_button_led_bytes_app import AppState +from src.monitor.monitor_serial_button_led_bytes_app import ButtonLEDApp from src.util import log from src.protocol.codec import CodecParseException from src.service.ws import SocketInterface from src.domain.death import Death from functools import partial -from bitstring import BitArray LOGGER = log.timed_named_logger("monitor_button_led_bytes") @@ -36,11 +36,13 @@ def handle_finish( @staticmethod def render_incoming_message(app_state: AppState, incoming_message: SerialMonitorMessageToClient): - for byte_int in incoming_message.content_bytes: - bit_array_instance = BitArray(hex=hex(byte_int)) - bits = bit_array_instance.bin.zfill(8) - for i, c in enumerate(bits): - app_state.indexed_led_change(i, c == '1') + for byte in incoming_message.content_bytes: + fancy_byte_result = FancyByte.fromInt(byte) + if isinstance(fancy_byte_result, Err): return + fancy_byte = fancy_byte_result.value + bits = fancy_byte.to_binary_bits() + for index, is_on in enumerate(bits): + app_state.indexed_led_change(fancy_byte, index, is_on) @staticmethod def render_message_data_or_finish( @@ -53,6 +55,7 @@ def render_message_data_or_finish( # Handle message failures if death.gracing: + handle_finish() return None elif isinstance(incoming_message_result, Err) \ and isinstance(incoming_message_result.value, ConnectionClosedError): @@ -108,8 +111,8 @@ def send_on_button_click(button_index: int): asyncio_loop.add_signal_handler(getattr(signal, signame), handle_finish) # Start app - (_ButtonLEDApp, run_button_led_app) = define_app() - run_button_led_app(state) + loop = asyncio.get_event_loop() + loop.create_task(ButtonLEDApp.run_with_state(state)) # Run monitor loop while not state.death.gracing: @@ -124,6 +127,3 @@ def send_on_button_click(button_index: int): result = self.render_message_data_or_finish(state, state.death, handle_finish, incoming_message_result) if result is not None: return result - -# Export class as 'monitor' for explicit importing -monitor = MonitorSerialButtonLedBytes diff --git a/client/src/monitor/monitor_serial_button_led_bytes_app.py b/client/src/monitor/monitor_serial_button_led_bytes_app.py index 983c434..e69578d 100644 --- a/client/src/monitor/monitor_serial_button_led_bytes_app.py +++ b/client/src/monitor/monitor_serial_button_led_bytes_app.py @@ -1,8 +1,14 @@ """Module for functionality related to serial socket monitor as button/led byte stream, specifically graphics/UI""" - import asyncio import sys -from typing import Callable, Optional, Any +from typing import Callable, Optional + +from result import Err +from textual import events +from textual.app import App +from textual.views import GridView +from textual.widgets import Button +from src.domain.fancy_byte import FancyByte from src.util import log from src.domain.death import Death @@ -11,35 +17,50 @@ class AppState: death = Death() - on_indexed_button_click = None - on_indexed_led_change = None + on_indexed_button_click = [] + on_indexed_led_change = [] + last_byte_in: FancyByte = FancyByte(0) + last_byte_out: FancyByte = FancyByte(0) + bytes_in = 0 + bytes_out = 0 + + def stats(self): + quit = "Quit with CTRL-C or Esc" + i = str(int(self.bytes_in) - 1) if self.bytes_in > 0 else "?" + o = str(int(self.bytes_out) - 1) if self.bytes_out > 0 else "?" + lbi = f"last byte #{i} in: {self.last_byte_in.to_hex_str()}" + lbo = f"last byte #{o} out: {self.last_byte_out.to_hex_str()}" + return f"{quit}, {lbi}, {lbo}" def set_on_indexed_button_click( self, on_indexed_button_click: Callable[[int], None] ): - self.on_indexed_button_click = on_indexed_button_click + self.on_indexed_button_click.append(on_indexed_button_click) def indexed_button_click( self, button_index: int ): if self.on_indexed_button_click is not None: - self.on_indexed_button_click(button_index) + for c in self.on_indexed_button_click: + c(button_index) def set_on_indexed_led_change( self, - on_indexed_led_change: Callable[[int, bool], None] + on_indexed_led_change: Callable[[FancyByte, int, bool], None] ): - self.on_indexed_led_change = on_indexed_led_change + self.on_indexed_led_change.append(on_indexed_led_change) def indexed_led_change( self, + fancy_byte: FancyByte, led_index: int, led_on: bool ): if self.on_indexed_led_change is not None: - self.on_indexed_led_change(led_index, led_on) + for c in self.on_indexed_led_change: + c(fancy_byte, led_index, led_on) class AppStateStorage: @@ -51,148 +72,109 @@ def update(self, state: AppState): hacked_global_app_state_storage = AppStateStorage() - -def is_kivy_available() -> bool: - try: - import kivy - return True - except ImportError: - return False - - -def define_app(): - if not is_kivy_available(): - raise Exception("Kivy graphics library not available") - - import kivy - from kivy.app import App - from kivy.uix.button import Button - from kivy.uix.gridlayout import GridLayout - from kivy.uix.label import Label - from kivy.core.window import Window - from kivy.properties import NumericProperty - - kivy.require("2.0.0") - - class MyButton(Button): - key_index = NumericProperty() - - def __init__(self, **kwargs): - super(MyButton, self).__init__(**kwargs) - - def on_press(self): - self.background_color = (1, 1, 1, 0.7) - - def on_release(self): - hacked_global_app_state_storage.state.indexed_button_click(self.key_index) - self.background_color = (1, 1, 1, 0.9) - - class ButtonLEDScreen(GridLayout): - """Kivy screen for interacting with LEDs and buttons""" - leds = [ - Button( - background_down='', - background_normal='', - background_color=(1, 0, 0, 0.2), - text=f'LED{i}', - font_size=14, - outline_color=(1, 1, 1, 1)) +# Buttons +button_keys = [ + "q", "w", "e", "r", "t", "y", "u", "i", + "a", "s", "d", "f", "g", "h", "j", "k", + "z", "x", "c", "v", "b", "n", "m", ",", +] + +class ButtonLEDScreen(GridView): + """Screen for interacting with LEDs and buttons""" + + # Colors + DARK = "white on rgb(51,51,51)" + LIGHT = "black on rgb(165,165,165)" + RED = "white on rgb(215,0,0)" + + def on_mount(self, event: events.Mount) -> None: + """Event when widget is first mounted (added to a parent view).""" + # Make all the LEDs + self.leds = [ + Button(f"LED{7 - i} | off", style=self.DARK, name=f"LED{7 - i}") for i in range(0, 8) ] - button_keys = [ - "q", "w", "e", "r", "t", "y", "u", "i", - "a", "s", "d", "f", "g", "h", "j", "k", - "z", "x", "c", "v", "b", "n", "m", ",", - ] - buttons = [ - MyButton( - background_down='', - background_normal='', - background_color=(1, 1, 1, 0.9), - key_index=i, - text=f'BTN{i} [{k}]', - font_size=14, - color=(0, 0, 0, 1)) + + # Make all the buttons + self.buttons = [ + Button(f"BTN{i} | {k}", style=self.LIGHT, name=f"BTN{i} | {k}") for (i, k) in enumerate(button_keys) ] - def __init__(self, **kwargs): - # Initialize Kivy GridLayout - super(ButtonLEDScreen, self).__init__(**kwargs) + # Set basic grid settings + self.grid.set_gap(2, 1) + self.grid.set_gutter(1) + self.grid.set_align("center", "center") + + # Create rows / columns / areas + self.grid.add_column("col", max_size=30, repeat=8) + self.grid.add_row("buttons", max_size=15, repeat=4) + self.grid.add_row("statsrow", max_size=30, repeat=1) + self.grid.add_areas( + stats="col1-start|col8-end,statsrow", + ) - # Set grid column count - self.cols = 8 + # Place out widgets in to the layout + for led in self.leds: + self.grid.place(led) - # Add all LEDs - for led in self.leds: - self.add_widget(led) + for button in self.buttons: + self.grid.place(button) - # Bind LED state handler - def on_led_change(led_index: int, led_on: bool): - led: Label = self.leds[led_index] - LOGGER.debug(f"LED{led_index}: {led_on}") - if led_on: - led.background_color = (1, 0, 0, 0.8) - else: - led.background_color = (1, 0, 0, 0.2) + self.stats = Button(hacked_global_app_state_storage.state.stats(), style=self.DARK, name=f"stats") - hacked_global_app_state_storage.state.set_on_indexed_led_change(on_led_change) + def on_button_click(button_index: int): + hacked_global_app_state_storage.state.last_byte_out = FancyByte.fromInt(button_index).value + hacked_global_app_state_storage.state.bytes_out += 1 + self.stats.label = hacked_global_app_state_storage.state.stats() + hacked_global_app_state_storage.state.set_on_indexed_button_click(on_button_click) - # Add all buttons - for button in self.buttons: - self.add_widget(button) + def on_led_change(fancy_byte: FancyByte, led_index: int, led_on: bool): + if 0 <= led_index < len(self.leds): + self.leds[led_index].button_style = self.RED if led_on else self.DARK + i = str(7 - led_index) + s = "on" if led_on else "off" + self.leds[led_index].label = f"LED{i} | {s}" + hacked_global_app_state_storage.state.last_byte_in = fancy_byte + hacked_global_app_state_storage.state.bytes_in += 1 / 8 + self.stats.label = hacked_global_app_state_storage.state.stats() - # Bind keyboard button handler - def on_key_up(_window, key, _scancode): - self.on_key_action(True, key) - Window.bind(on_key_up=on_key_up) + hacked_global_app_state_storage.state.set_on_indexed_led_change(on_led_change) - def on_key_down(_window, key, _scancode, _codepoint, _modifier): - self.on_key_action(False, key) - Window.bind(on_key_down=on_key_down) + self.grid.place(stats=self.stats) - def on_key_action(self, is_up: bool, key: int): - def handle(button: Button): - if is_up: - button.on_release() - else: - button.on_press() - try: - key_index = self.button_keys.index(chr(key)) - handle(self.buttons[key_index]) - except ValueError as _e: - pass +class ButtonLEDApp(App): + """TUI app for interacting with LEDs and buttons""" + async def on_mount(self) -> None: + """Mount the calculator widget.""" + await self.view.dock(ButtonLEDScreen()) - class ButtonLEDApp(App): - """GUI app for interacting with LEDs and buttons""" + async def on_load(self): + await self.bind("escape", "quit") - def build(self): - return ButtonLEDScreen() + def on_key(self, event): + if event.key in button_keys: + button_index = button_keys.index(event.key) + hacked_global_app_state_storage.state.indexed_button_click(button_index) - def run_button_led_app(app_state: AppState): + @staticmethod + async def run_with_state(app_state: AppState): hacked_global_app_state_storage.update(app_state) app = ButtonLEDApp() - async def app_lifecycle(): - LOGGER.info("Starting app") - await app.async_run() - app.stop() - LOGGER.info("Stopping app") - app_state.death.grace() - - asyncio_loop = asyncio.get_event_loop() - asyncio_loop.create_task(app_lifecycle()) - - return (ButtonLEDApp, run_button_led_app) + LOGGER.info("Starting app") + res = await app_state.death.or_awaitable(app.process_messages()) + if isinstance(res, Err): + await app.shutdown() + LOGGER.info("Force stopping app") + LOGGER.info("App finished") + app_state.death.grace() if __name__ == '__main__': app_state = AppState() - hacked_global_app_state_storage.update(app_state) - (ButtonLEDApp, _run_button_led_app) = define_app() - app = ButtonLEDApp() - asyncio_loop = asyncio.get_event_loop() - asyncio_loop.run_until_complete(app.async_run()) + loop = asyncio.get_event_loop() + loop.run_until_complete(ButtonLEDApp.run_with_state(app_state)) sys.exit(0) diff --git a/client/src/service/cli.py b/client/src/service/cli.py index 8f07949..53886ad 100755 --- a/client/src/service/cli.py +++ b/client/src/service/cli.py @@ -236,7 +236,6 @@ async def quick_run( software_name: Optional[str], hardware_id_str: str, monitor_type_str: str, - monitor_script_path_str: Optional[str] ) -> Result[DIPRunnable, DIPClientError]: pass @@ -781,7 +780,6 @@ async def quick_run( software_name: Optional[str], hardware_id_str: str, monitor_type_str: str, - monitor_script_path_str: Optional[str] ) -> Result[DIPRunnable, DIPClientError]: # Upload software to platform LOGGER.info("Uploading software to platform") @@ -797,7 +795,7 @@ async def quick_run( # Create serial monitor connection to board LOGGER.info("Configuring serial connection monitor with board") monitor_result = CLI.hardware_serial_monitor( - config_path_str, control_server_str, hardware_id_str, monitor_type_str, monitor_script_path_str) + config_path_str, control_server_str, hardware_id_str, monitor_type_str) if isinstance(monitor_result, Err): return Err(monitor_result.value) # Open stream in background LOGGER.info("Opening video stream in browser") @@ -912,7 +910,7 @@ async def execute_runnable_result( print_error(runnable_result.value.text()) return sys.exit(1) runtime_result = await runnable_result.value.run() - await asyncio.sleep(0.1) # Hacks to yield to event loop + await asyncio.sleep(0.5) # Hacks to yield to event loop if runtime_result is not None: print_error(runtime_result.text()) return sys.exit(1) diff --git a/client/src/service/click.py b/client/src/service/click.py index cb7302d..7c3e054 100755 --- a/client/src/service/click.py +++ b/client/src/service/click.py @@ -164,7 +164,7 @@ help='Port number for VLC video source (if not is_stream_existing), default: 8081' ) -@click.group() +@click.group(context_settings=dict(max_content_width=300)) def cli_client(): """DIP Testbed Agent is a command line tool that serves as a remote microcontroller management middleman for the DIP Testbed Backend @@ -583,7 +583,6 @@ async def exec(): @SOFTWARE_NAME_OPTION @HARDWARE_ID_OPTION @MONITOR_TYPE_OPTION -@MONITOR_SCRIPT_PATH_OPTION def quick_run( config_path_str: Optional[str], control_server_str: Optional[str], @@ -594,7 +593,6 @@ def quick_run( software_name: Optional[str], hardware_id_str: str, monitor_type_str: str, - monitor_script_path_str: str ): """Upload, forward & monitor board software""" async def exec(): @@ -608,8 +606,7 @@ async def exec(): software_file_path, software_name, hardware_id_str, - monitor_type_str, - monitor_script_path_str + monitor_type_str ), "Finished quick run") asyncio.run(exec()) @@ -646,7 +643,7 @@ def agent_hardware_video( audio_buffer_size: Optional[int], port: Optional[int] ): - """Hardware video stream broadcast""" + """Video stream broadcast (Linux specific)""" async def exec(): return await CLI.execute_runnable_result( await CLI.agent_hardware_camera(