diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..2f1ffa0 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,28 @@ +name: pytest + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.7 + uses: actions/setup-python@v1 + with: + python-version: 3.7 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + wget https://github.com/sccn/liblsl/releases/download/1.13.0-b13/liblsl-1.13.0-Linux64-bionic.deb + sudo dpkg -i liblsl-1.13.0-Linux64-bionic.deb + pip install -U pip + pip install git+https://github.com/labstreaminglayer/liblsl-Python.git + pip install -r requirements.txt + pip install . + - name: Test with pytest + run: | + pip install pytest + pytest diff --git a/.gitignore b/.gitignore index 7ba05b9..09b1a92 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ */*_pycache_* */*.mypy_cache* *.mypy_cache* +mypy.ini +.vscode* +lib/* \ No newline at end of file diff --git a/lib/standard_1005.elc b/lib/standard_1005.elc deleted file mode 100644 index 4e69532..0000000 --- a/lib/standard_1005.elc +++ /dev/null @@ -1,698 +0,0 @@ -# ASA electrode file -ReferenceLabel avg -UnitPosition mm -NumberPositions= 346 -Positions --86.0761 -19.9897 -47.9860 -85.7939 -20.0093 -48.0310 -0.0083 86.8110 -39.9830 --29.4367 83.9171 -6.9900 -0.1123 88.2470 -1.7130 -29.8723 84.8959 -7.0800 --48.9708 64.0872 -47.6830 --54.8397 68.5722 -10.5900 --45.4307 72.8622 5.9780 --33.7007 76.8371 21.2270 --18.4717 79.9041 32.7520 -0.2313 80.7710 35.4170 -19.8203 80.3019 32.7640 -35.7123 77.7259 21.9560 -46.5843 73.8078 6.0340 -55.7433 69.6568 -10.7550 -50.4352 63.8698 -48.0050 --70.1019 41.6523 -49.9520 --70.2629 42.4743 -11.4200 --64.4658 48.0353 16.9210 --50.2438 53.1112 42.1920 --27.4958 56.9311 60.3420 -0.3122 58.5120 66.4620 -29.5142 57.6019 59.5400 -51.8362 54.3048 40.8140 -67.9142 49.8297 16.3670 -73.0431 44.4217 -12.0000 -72.1141 42.0667 -50.4520 --84.0759 14.5673 -50.4290 --80.7750 14.1203 -11.1350 --77.2149 18.6433 24.4600 --60.1819 22.7162 55.5440 --34.0619 26.0111 79.9870 -0.3761 27.3900 88.6680 -34.7841 26.4379 78.8080 -62.2931 23.7228 55.6300 -79.5341 19.9357 24.4380 -81.8151 15.4167 -11.3300 -84.1131 14.3647 -50.5380 --85.8941 -15.8287 -48.2830 --84.1611 -16.0187 -9.3460 --80.2801 -13.7597 29.1600 --65.3581 -11.6317 64.3580 --36.1580 -9.9839 89.7520 -0.4009 -9.1670 100.2440 -37.6720 -9.6241 88.4120 -67.1179 -10.9003 63.5800 -83.4559 -12.7763 29.2080 -85.0799 -15.0203 -9.4900 -85.5599 -16.3613 -48.2710 --85.6192 -46.5147 -45.7070 --84.8302 -46.0217 -7.0560 --79.5922 -46.5507 30.9490 --63.5562 -47.0088 65.6240 --35.5131 -47.2919 91.3150 -0.3858 -47.3180 99.4320 -38.3838 -47.0731 90.6950 -66.6118 -46.6372 65.5800 -83.3218 -46.1013 31.2060 -85.5488 -45.5453 -7.1300 -86.1618 -47.0353 -45.8690 --73.0093 -73.7657 -40.9980 --72.4343 -73.4527 -2.4870 --67.2723 -76.2907 28.3820 --53.0073 -78.7878 55.9400 --28.6203 -80.5249 75.4360 -0.3247 -81.1150 82.6150 -31.9197 -80.4871 76.7160 -55.6667 -78.5602 56.5610 -67.8877 -75.9043 28.0910 -73.0557 -73.0683 -2.5400 -73.8947 -74.3903 -41.2200 --54.9104 -98.0448 -35.4650 --54.8404 -97.5279 2.7920 --48.4244 -99.3408 21.5990 --36.5114 -100.8529 37.1670 --18.9724 -101.7680 46.5360 -0.2156 -102.1780 50.6080 -19.8776 -101.7930 46.3930 -36.7816 -100.8491 36.3970 -49.8196 -99.4461 21.7270 -55.6666 -97.6251 2.7300 -54.9876 -98.0911 -35.5410 --29.4134 -112.4490 8.8390 -0.1076 -114.8920 14.6570 -29.8426 -112.1560 8.8000 --29.8184 -114.5700 -29.2160 -0.0045 -118.5650 -23.0780 -29.7416 -114.2600 -29.2560 --43.2897 75.8552 -28.2440 --38.5517 79.9532 -4.9950 --27.9857 82.4591 2.7020 --17.1947 84.8491 10.0270 --5.9317 86.8780 16.2000 -7.1053 87.0740 16.4690 -18.9233 85.5969 11.4430 -28.6443 82.9759 2.8280 -39.3203 80.6868 -4.7250 -43.8223 76.5418 -28.3070 --63.2538 53.8573 -30.3160 --61.3508 58.7992 0.8970 --50.7998 64.0412 23.0890 --34.3157 68.3931 41.1880 --11.4357 70.7561 50.3480 -13.4793 71.2010 51.1750 -36.1833 69.1509 41.2540 -52.3972 65.0708 22.8620 -62.9152 60.0448 0.6300 -64.3342 54.5998 -30.4440 --79.0669 28.0813 -31.2530 --74.4999 31.3003 4.8460 --65.2379 36.4282 36.1440 --44.4098 40.7622 61.6900 --15.4238 43.6600 77.6820 -17.5922 44.0540 77.7880 -45.8532 41.6228 60.6470 -67.1281 37.7998 35.2960 -78.0531 32.9817 4.4830 -80.0971 28.5137 -31.3380 --84.1250 -1.8467 -29.7940 --82.3550 0.8263 8.5790 --74.6920 4.3033 45.3070 --51.0509 7.1772 74.3770 --18.2190 9.0941 92.5290 -18.7870 9.2479 91.5620 -51.8851 7.7978 73.5070 -77.0020 5.3357 45.3500 -83.8880 1.9457 8.5010 -84.1230 -1.8083 -29.6380 --86.9731 -32.2157 -27.8480 --85.5651 -30.6287 11.1530 --76.4071 -29.7307 49.2170 --52.9281 -28.9058 80.3040 --18.3541 -28.3219 98.2200 -20.2199 -28.1481 98.1720 -55.1139 -28.3862 80.4740 -79.0059 -28.9863 49.6280 -85.9999 -29.8203 11.2480 -88.6249 -32.2723 -28.0000 --78.1602 -60.7567 -23.8240 --76.6802 -60.8317 12.8800 --68.1152 -62.9747 47.2520 --46.9142 -64.6908 75.2960 --15.8202 -65.5999 91.1640 -19.4198 -65.5950 92.4050 -50.6738 -64.4822 76.1300 -71.0958 -62.6243 47.3280 -78.5198 -60.4323 12.9020 -78.9027 -60.9553 -23.8050 --64.5973 -87.6558 -19.0140 --62.9593 -87.5028 12.9520 --54.0103 -89.8988 37.3320 --35.8874 -91.6669 55.5040 --12.0474 -92.6069 65.5080 -13.9226 -92.6940 66.9580 -37.7986 -91.6291 56.7330 -54.6087 -89.6402 37.0350 -63.1117 -87.2282 12.8560 -65.0137 -87.8062 -18.9520 --42.8624 -108.0730 -13.1510 --40.1204 -107.1290 12.0610 --31.9514 -108.2520 23.0470 --19.8624 -108.9420 29.7600 --6.9194 -109.2600 32.7100 -6.8036 -109.1630 31.5820 -20.2936 -108.9140 28.9440 -32.1756 -108.2520 22.2550 -41.0976 -107.2450 12.1380 -43.8946 -109.1270 -13.1700 --14.8504 -117.9870 -6.9200 -15.0946 -118.0180 -6.9330 --14.8107 87.2351 -4.4770 -15.1623 88.0910 -4.5510 --54.8298 66.4132 -29.7040 --51.1757 70.8362 -1.7550 --39.6407 74.8671 13.6780 --27.2187 78.7091 28.3750 --9.1977 80.6051 35.1330 -10.4823 80.8650 35.3590 -28.5803 79.3029 28.4700 -40.9403 75.7399 13.8600 -52.0293 71.8468 -1.9200 -55.7542 67.1698 -29.8240 --71.5079 41.1193 -30.8540 --68.5558 45.2843 3.0020 --58.4878 50.6722 30.1920 --39.9798 55.2601 52.6000 --13.3838 57.9021 64.3320 -15.8342 58.4559 64.9920 -41.7942 56.2259 51.4990 -60.0522 52.0858 28.7080 -71.9592 47.1917 2.4750 -72.7981 41.8218 -31.0260 --82.9559 13.3203 -30.8080 --80.1139 16.3903 6.8500 --71.2099 20.8203 41.3240 --48.5119 24.5292 69.1360 --17.3439 27.0241 86.9230 -18.4181 27.2709 86.4370 -49.5481 25.2378 68.4300 -73.2191 22.0067 41.2970 -81.5801 17.6837 6.5640 -83.3711 13.5477 -30.7490 --85.1321 -17.0557 -28.7310 --82.9461 -14.8827 10.0090 --75.2941 -12.6397 47.9040 --51.5811 -10.7548 78.0350 --18.2790 -9.4319 97.3560 -19.6780 -9.3041 95.7060 -53.8059 -10.1442 77.7300 -78.1249 -11.7353 47.8400 -85.1369 -13.9063 9.8900 -86.0999 -17.0883 -28.7560 --84.8102 -47.2457 -26.2200 --82.7042 -46.2977 11.9740 --73.3012 -46.7917 49.1090 --51.0492 -47.1758 80.0160 --17.3542 -47.3419 97.4100 -20.6798 -47.2321 98.0720 -53.9968 -46.8902 80.0770 -76.5498 -46.3733 49.1400 -85.1998 -45.8073 12.1020 -85.4428 -47.2213 -26.1760 --72.1773 -74.6277 -21.5360 --70.1133 -74.8677 12.9990 --61.7283 -77.6238 43.0280 --41.6733 -79.7528 66.7150 --13.9613 -81.0029 81.0030 -17.2977 -80.9810 81.6410 -44.7477 -79.6111 67.6550 -63.6267 -77.3022 43.1190 -72.1037 -74.4993 13.0250 -73.2817 -75.0773 -21.5760 --54.7754 -98.9768 -16.1930 --51.9284 -98.4438 12.3040 --43.3424 -100.1629 30.0090 --28.0074 -101.3610 42.3790 --9.5034 -102.0600 49.4180 -10.2356 -102.0290 48.9420 -28.6476 -101.3901 42.1380 -44.2206 -100.2191 29.8080 -52.8386 -98.5360 12.2500 -55.8596 -99.8940 -16.2080 --14.8054 -115.1000 11.8290 -15.1456 -115.1910 11.8330 --15.1584 -118.2420 -26.0480 -15.1286 -118.1510 -26.0810 --36.1247 72.3801 -45.8520 --43.5117 78.5802 -9.2400 --33.2847 81.2071 -1.1400 --22.3517 83.5621 6.0710 --12.2417 86.1941 14.1880 -0.1703 87.3220 17.4420 -13.6223 86.7579 15.3020 -24.1013 84.3769 7.4330 -33.9133 81.8119 -1.0350 -43.9483 79.2958 -9.3000 -37.7123 72.1679 -46.1970 --59.3398 52.6802 -48.7700 --63.2618 55.9922 -11.1730 --55.8198 61.3962 11.8840 --43.3817 66.3672 32.8110 --23.5817 69.9171 47.2930 -0.2763 71.2800 52.0920 -25.5583 70.5559 47.8270 -45.1522 67.2748 32.7310 -58.0002 62.5998 11.9000 -64.6732 57.2738 -11.4600 -60.6012 52.2668 -49.0380 --78.4839 28.7703 -50.5220 --76.6149 28.6533 -11.5080 --71.5059 33.9263 20.9930 --55.9399 38.7162 49.7880 --30.6548 42.4151 71.0400 -0.3512 44.0740 79.1410 -32.6451 43.1009 70.7950 -57.5042 39.8518 48.8110 -74.2501 35.4997 20.3800 -79.0341 30.3437 -11.9970 -79.9201 28.9417 -50.9140 --87.3620 -0.5147 -49.8370 --82.6680 -0.9417 -10.2840 --80.1330 2.5853 27.3120 --64.1610 5.8313 60.8850 --35.7490 8.3091 85.4590 -0.3911 9.5080 95.5600 -36.0700 8.6519 83.8320 -65.1640 6.6198 60.0520 -81.5440 3.6637 27.2010 -83.1680 0.1817 -10.3640 -85.3930 -0.9523 -49.5200 --86.6321 -31.2377 -47.1780 --85.9331 -31.0927 -8.4740 --81.5431 -30.1727 30.2730 --66.1281 -29.2957 65.8980 --36.9301 -28.5699 91.7340 -0.3959 -28.1630 101.2690 -38.5399 -28.2251 90.9760 -68.8539 -28.6403 66.4100 -84.5529 -29.3783 30.8780 -85.9999 -30.2803 -8.4350 -86.7619 -31.7313 -47.2530 --80.7152 -60.6457 -43.5940 --78.5992 -59.7237 -4.7580 --73.6642 -61.9227 30.3800 --59.4112 -63.9248 62.6720 --32.7283 -65.3199 85.9440 -0.3658 -65.7500 94.0580 -35.8918 -65.1381 85.9800 -62.2558 -63.6152 62.7190 -76.6708 -61.5483 30.5430 -79.3188 -59.3033 -4.8400 -81.5598 -61.2153 -43.8000 --64.5703 -86.4318 -38.3240 --64.5833 -86.2218 0.0330 --58.7123 -88.7048 25.1930 --46.1603 -90.8878 47.4460 --24.6483 -92.2919 62.0760 -0.2727 -92.7580 67.3420 -26.4367 -92.2951 63.1990 -47.1437 -90.7122 47.6780 -60.8127 -88.5042 25.6620 -65.1517 -85.9432 -0.0090 -65.0377 -86.7182 -38.4480 --43.1284 -107.5160 -32.3870 --42.9764 -106.4930 5.7730 --36.2344 -107.7160 17.7500 --25.9844 -108.6160 26.5440 --13.6644 -109.2660 32.8560 -0.1676 -109.2760 32.7900 -13.6506 -109.1060 30.9360 -26.6636 -108.6680 26.4150 -37.7006 -107.8400 18.0690 -43.6696 -106.5990 5.7260 -43.1766 -107.4440 -32.4630 --29.3914 -114.5110 -10.0200 -0.0525 -119.3430 -3.9360 -29.5526 -113.6360 -10.0510 --84.1611 -16.0187 -9.3460 --72.4343 -73.4527 -2.4870 -85.0799 -15.0203 -9.4900 -73.0557 -73.0683 -2.5400 --86.0761 -44.9897 -67.9860 - 85.7939 -45.0093 -68.0310 --86.0761 -24.9897 -67.9860 - 85.7939 -25.0093 -68.0310 -Labels -LPA -RPA -Nz -Fp1 -Fpz -Fp2 -AF9 -AF7 -AF5 -AF3 -AF1 -AFz -AF2 -AF4 -AF6 -AF8 -AF10 -F9 -F7 -F5 -F3 -F1 -Fz -F2 -F4 -F6 -F8 -F10 -FT9 -FT7 -FC5 -FC3 -FC1 -FCz -FC2 -FC4 -FC6 -FT8 -FT10 -T9 -T7 -C5 -C3 -C1 -Cz -C2 -C4 -C6 -T8 -T10 -TP9 -TP7 -CP5 -CP3 -CP1 -CPz -CP2 -CP4 -CP6 -TP8 -TP10 -P9 -P7 -P5 -P3 -P1 -Pz -P2 -P4 -P6 -P8 -P10 -PO9 -PO7 -PO5 -PO3 -PO1 -POz -PO2 -PO4 -PO6 -PO8 -PO10 -O1 -Oz -O2 -I1 -Iz -I2 -AFp9h -AFp7h -AFp5h -AFp3h -AFp1h -AFp2h -AFp4h -AFp6h -AFp8h -AFp10h -AFF9h -AFF7h -AFF5h -AFF3h -AFF1h -AFF2h -AFF4h -AFF6h -AFF8h -AFF10h -FFT9h -FFT7h -FFC5h -FFC3h -FFC1h -FFC2h -FFC4h -FFC6h -FFT8h -FFT10h -FTT9h -FTT7h -FCC5h -FCC3h -FCC1h -FCC2h -FCC4h -FCC6h -FTT8h -FTT10h -TTP9h -TTP7h -CCP5h -CCP3h -CCP1h -CCP2h -CCP4h -CCP6h -TTP8h -TTP10h -TPP9h -TPP7h -CPP5h -CPP3h -CPP1h -CPP2h -CPP4h -CPP6h -TPP8h -TPP10h -PPO9h -PPO7h -PPO5h -PPO3h -PPO1h -PPO2h -PPO4h -PPO6h -PPO8h -PPO10h -POO9h -POO7h -POO5h -POO3h -POO1h -POO2h -POO4h -POO6h -POO8h -POO10h -OI1h -OI2h -Fp1h -Fp2h -AF9h -AF7h -AF5h -AF3h -AF1h -AF2h -AF4h -AF6h -AF8h -AF10h -F9h -F7h -F5h -F3h -F1h -F2h -F4h -F6h -F8h -F10h -FT9h -FT7h -FC5h -FC3h -FC1h -FC2h -FC4h -FC6h -FT8h -FT10h -T9h -T7h -C5h -C3h -C1h -C2h -C4h -C6h -T8h -T10h -TP9h -TP7h -CP5h -CP3h -CP1h -CP2h -CP4h -CP6h -TP8h -TP10h -P9h -P7h -P5h -P3h -P1h -P2h -P4h -P6h -P8h -P10h -PO9h -PO7h -PO5h -PO3h -PO1h -PO2h -PO4h -PO6h -PO8h -PO10h -O1h -O2h -I1h -I2h -AFp9 -AFp7 -AFp5 -AFp3 -AFp1 -AFpz -AFp2 -AFp4 -AFp6 -AFp8 -AFp10 -AFF9 -AFF7 -AFF5 -AFF3 -AFF1 -AFFz -AFF2 -AFF4 -AFF6 -AFF8 -AFF10 -FFT9 -FFT7 -FFC5 -FFC3 -FFC1 -FFCz -FFC2 -FFC4 -FFC6 -FFT8 -FFT10 -FTT9 -FTT7 -FCC5 -FCC3 -FCC1 -FCCz -FCC2 -FCC4 -FCC6 -FTT8 -FTT10 -TTP9 -TTP7 -CCP5 -CCP3 -CCP1 -CCPz -CCP2 -CCP4 -CCP6 -TTP8 -TTP10 -TPP9 -TPP7 -CPP5 -CPP3 -CPP1 -CPPz -CPP2 -CPP4 -CPP6 -TPP8 -TPP10 -PPO9 -PPO7 -PPO5 -PPO3 -PPO1 -PPOz -PPO2 -PPO4 -PPO6 -PPO8 -PPO10 -POO9 -POO7 -POO5 -POO3 -POO1 -POOz -POO2 -POO4 -POO6 -POO8 -POO10 -OI1 -OIz -OI2 -T3 -T5 -T4 -T6 -M1 -M2 -A1 -A2 diff --git a/localite/.vscode/settings.json b/localite/.vscode/settings.json deleted file mode 100644 index ef55170..0000000 --- a/localite/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "python.pythonPath": "C:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\Python37_64\\python.exe" -} \ No newline at end of file diff --git a/localite/_client.py b/localite/_client.py new file mode 100644 index 0000000..33e02f2 --- /dev/null +++ b/localite/_client.py @@ -0,0 +1,323 @@ +# -*- coding: utf-8 -*- +""" +A client to communicate with the Localite to control the magventure. + +@author: Robert Guggenberger +""" +# %% +import socket +import json +import pylsl +import threading +import time +from typing import Callable +from logging import getLogger +logger = getLogger("LocaliteClient") + +def is_port_in_use(host, port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex((host, port)) == 0 + + +def get_client(host='127.0.0.1', port=6666): + if is_port_in_use("127.0.0.1", port+1): + kill_client() + client = SmartClient(host=host, port=port) + return client.start() + +def kill_client(host='127.0.0.1', port=6667): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect((host, port)) + msg = json.dumps({"command":"die"}).encode("ascii") + s.send(msg) + + + +# %% +class SmartClient(): + def __init__(self, name:str="localite_marker", host:str="127.0.0.1", port:int=6666): + self.name = name + self.port = port + self.host = host + + def stop(self): + kill_client() + + def start(self): + port = self.port + host = self.host + name = self.name + client = ReceiverClient(name=name, host=host, port=port) + killer = ReceiverServer(host="127.0.0.1", port=port+1, foo=client.stop) + client.start() + killer.start() + return client + + +# %% + +def read_msg(client): + 'receive byte for byte to read the header telling the message length' + #parse the message until it is a valid json + msg = bytearray(b' ') + while True: + try: + prt = client.recv(1) + msg += prt + return json.loads(msg.decode('ascii')) # because the first byte is b' ' + except json.decoder.JSONDecodeError: + pass + except Exception as e: + print(e) + break + + return None + +class ReceiverServer(threading.Thread): + + def __init__(self, host="127.0.0.1", port: int = 6667, foo:Callable=None): + threading.Thread.__init__(self) + self.host = host + self.port = port + self.stop_client = foo + self.is_running = threading.Event() + + def stop(self): + self.is_running.clear() + + def run(self): + interface = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + interface.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + interface.settimeout(1) + interface.bind((self.host, self.port)) + interface.listen(1) + print('Server managing Localite Clients opened at {0}:{1}'.format( + self.host, self.port)) + self.is_running.set() + while self.is_running.is_set(): + message = {"command":"live"} + try: + client, address = interface.accept() + try: + message = read_msg(client) + except socket.timeout: + print('Client from {address} timed out') + finally: + client.shutdown(2) + client.close() + except socket.timeout: + pass + + if message["command"] == "die": + print("Attempting to stop client") + self.stop_client() + self.stop() + else: + print("Shutting down ReceiverServer at", self.host, self.port) + + + +# %% + + +class ReceiverClient(threading.Thread): + "LSL based software marker streamer" + + def __init__(self, name:str="localite_marker", host:str="127.0.0.1", port=6666): + threading.Thread.__init__(self) + self.host = host + self.port = port + self.name = name + + self.client_lock = threading.Lock() + self.client = Client(host=self.host, port=self.port) + + source_id = '_'.join((socket.gethostname(), name)) + self.info = pylsl.StreamInfo(self.name, type='Markers', channel_count=1, + nominal_srate=0, channel_format='string', source_id=source_id) + self.outlet = pylsl.StreamOutlet(self.info) + self.outlet_lock = threading.Lock() + self.is_running = threading.Event() + + def stop(self): + print("Attempting to stop") + self.is_running.clear() + print("Shutting down ReceiverClient for", self.host, self.port) + del self.outlet + + def run(self): + print('Client manager listing to localite opened at {0}:{1}'.format( + self.host, self.port)) + self.is_running.set() + print(self.info.as_xml()) + while self.is_running.is_set(): + try: + with self.client_lock: + key, val = self.client.listen() + except (ConnectionResetError, ConnectionRefusedError): + print("Connection Problems. Check that host={self.host} is valid.") + + tstamp = pylsl.local_clock() + marker = json.dumps({key: val}) + + if key in ('coil_0_didt', 'coil_1_didt'): # localite has triggered + print(f'Pushed {marker} at {tstamp}') + with self.outlet_lock: + self.outlet.push_sample([marker], tstamp) + else: + print("Shutting down ReceiverClient for", self.host, self.port) + + def send(self, msg: str): + with self.client_lock: + try: + self.client.send(msg) + print(f'Send {msg} at {pylsl.local_clock()}') + except (ConnectionResetError, ConnectionRefusedError): + print("Connection Problems. I'll keep on trying to connect") + time.sleep(5) + self.client = Client(host=self.host, port=self.port) + + def request(self, msg): + try: + with self.client_lock: + answer = self.client.request(msg) + print(f'Received {answer} for {msg} at {pylsl.local_clock()}') + return answer + except (ConnectionResetError, ConnectionRefusedError): + print("Connection Problems. Retry") + return None + + def trigger(self, id: str): + marker = '{"single_pulse":"COIL_' + id + '"}' + try: + with self.client_lock: + self.client.send(marker) + except (ConnectionResetError, ConnectionRefusedError): + print("Connection Problems. Aborting for safety") + return None + + tstamp = pylsl.local_clock() + print(f'Pushed {marker} at {tstamp}') + with self.outlet_lock: + self.outlet.push_sample([marker], tstamp) + + return tstamp + + def push_marker(self, marker:str): + + tstamp = pylsl.local_clock() + + print(f'Pushed {marker} at {tstamp}') + + with self.outlet_lock: + + self.outlet.push_sample([marker], tstamp) + + +# %% + + +class Client(object): + """ + A LocaliteJSON socket client used to communicate with a LocaliteJSON socket server. + + example + ------- + host = '127.0.0.1' + port = 6666 + client = Client(True) + client.connect(host, port).send(data) + response = client.recv() + client.close() + + example + ------- + response = Client().connect(host, port).send(data).recv_close() + """ + + socket = None + + def __init__(self, host, port=6666, timeout=None): + self.host = host + self.port = port + self.timeout = timeout + + def connect(self): + 'connect wth the remote server' + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.connect((self.host, self.port)) + self.socket.settimeout(self.timeout) + + def close(self): + 'closes the connection' + self.socket.shutdown(1) + self.socket.close() + del self.socket + + def write(self, data): + self.socket.sendall(data.encode('ascii')) + return self + + def read_byte(self, counter, buffer): + """read next byte from the TCP/IP bytestream and decode as ASCII""" + if counter is None: + counter = 0 + char = self.socket.recv(1).decode('ASCII') + buffer.append(char) + counter += {'{': 1, '}': -1}.get(char, 0) + return counter, buffer + + def read(self): + 'parse the message until it is a valid json' + counter = None + buffer = [] + while counter is not 0: + counter, buffer = self.read_byte(counter, buffer) + response = ''.join(buffer) + return self.decode(response) + + def listen(self): + self.connect() + msg = self.read() + self.close() + return msg + + def decode(self, msg: str, index=0): + # catches and interface error + msg = msg.replace('reason', '\"reason\"') + try: + decoded = json.loads(msg) + except json.JSONDecodeError as e: + print("JSONDecodeError: " + msg) + raise e + + key = list(decoded.keys())[index] + val = decoded[key] + return key, val + + def send(self, msg: str): + self.connect() + self.write(msg) + self.close() + + def request(self, msg="coil_0_amplitude"): + self.connect() + msg = '{"get":\"' + msg + '\"}' + self.write(msg) + key = val = '' + _, expected = self.decode(msg) + logger.debug(msg) + logger.debug(expected) + while key != expected: + key, val = self.read() + self.close() + return None if val == 'NONE' else val + + +# c = Client(host=host) +# %timeit -n 1 -r 1000 c.request() +# 5.22 ms ± 3.29 ms per loop (mean ± std. dev. of 1000 runs, 1 loop each) +if __name__ == "__main__": + + client = get_client(host="134.2.117.173") + diff --git a/localite/client.py b/localite/client.py index 33e02f2..ca9d5f6 100644 --- a/localite/client.py +++ b/localite/client.py @@ -1,323 +1,67 @@ -# -*- coding: utf-8 -*- -""" -A client to communicate with the Localite to control the magventure. - -@author: Robert Guggenberger -""" -# %% import socket +from pylsl import local_clock import json -import pylsl -import threading -import time -from typing import Callable -from logging import getLogger -logger = getLogger("LocaliteClient") - -def is_port_in_use(host, port): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - return s.connect_ex((host, port)) == 0 - - -def get_client(host='127.0.0.1', port=6666): - if is_port_in_use("127.0.0.1", port+1): - kill_client() - client = SmartClient(host=host, port=port) - return client.start() - -def kill_client(host='127.0.0.1', port=6667): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect((host, port)) - msg = json.dumps({"command":"die"}).encode("ascii") - s.send(msg) - - - -# %% -class SmartClient(): - def __init__(self, name:str="localite_marker", host:str="127.0.0.1", port:int=6666): - self.name = name - self.port = port - self.host = host - - def stop(self): - kill_client() - - def start(self): - port = self.port - host = self.host - name = self.name - client = ReceiverClient(name=name, host=host, port=port) - killer = ReceiverServer(host="127.0.0.1", port=port+1, foo=client.stop) - client.start() - killer.start() - return client - - -# %% - -def read_msg(client): - 'receive byte for byte to read the header telling the message length' - #parse the message until it is a valid json - msg = bytearray(b' ') - while True: - try: - prt = client.recv(1) - msg += prt - return json.loads(msg.decode('ascii')) # because the first byte is b' ' - except json.decoder.JSONDecodeError: - pass - except Exception as e: - print(e) - break - - return None - -class ReceiverServer(threading.Thread): - - def __init__(self, host="127.0.0.1", port: int = 6667, foo:Callable=None): - threading.Thread.__init__(self) - self.host = host - self.port = port - self.stop_client = foo - self.is_running = threading.Event() - - def stop(self): - self.is_running.clear() - - def run(self): - interface = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - interface.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - interface.settimeout(1) - interface.bind((self.host, self.port)) - interface.listen(1) - print('Server managing Localite Clients opened at {0}:{1}'.format( - self.host, self.port)) - self.is_running.set() - while self.is_running.is_set(): - message = {"command":"live"} - try: - client, address = interface.accept() - try: - message = read_msg(client) - except socket.timeout: - print('Client from {address} timed out') - finally: - client.shutdown(2) - client.close() - except socket.timeout: - pass - - if message["command"] == "die": - print("Attempting to stop client") - self.stop_client() - self.stop() - else: - print("Shutting down ReceiverServer at", self.host, self.port) +from typing import Dict, Any +class Client: + "Basic Client communicating with the MarkerServer" -# %% - - -class ReceiverClient(threading.Thread): - "LSL based software marker streamer" - - def __init__(self, name:str="localite_marker", host:str="127.0.0.1", port=6666): - threading.Thread.__init__(self) + def __init__(self, host="127.0.0.1", port: int = 6667, verbose=True): self.host = host self.port = port - self.name = name - - self.client_lock = threading.Lock() - self.client = Client(host=self.host, port=self.port) - - source_id = '_'.join((socket.gethostname(), name)) - self.info = pylsl.StreamInfo(self.name, type='Markers', channel_count=1, - nominal_srate=0, channel_format='string', source_id=source_id) - self.outlet = pylsl.StreamOutlet(self.info) - self.outlet_lock = threading.Lock() - self.is_running = threading.Event() - - def stop(self): - print("Attempting to stop") - self.is_running.clear() - print("Shutting down ReceiverClient for", self.host, self.port) - del self.outlet - - def run(self): - print('Client manager listing to localite opened at {0}:{1}'.format( - self.host, self.port)) - self.is_running.set() - print(self.info.as_xml()) - while self.is_running.is_set(): - try: - with self.client_lock: - key, val = self.client.listen() - except (ConnectionResetError, ConnectionRefusedError): - print("Connection Problems. Check that host={self.host} is valid.") - - tstamp = pylsl.local_clock() - marker = json.dumps({key: val}) - - if key in ('coil_0_didt', 'coil_1_didt'): # localite has triggered - print(f'Pushed {marker} at {tstamp}') - with self.outlet_lock: - self.outlet.push_sample([marker], tstamp) - else: - print("Shutting down ReceiverClient for", self.host, self.port) - - def send(self, msg: str): - with self.client_lock: - try: - self.client.send(msg) - print(f'Send {msg} at {pylsl.local_clock()}') - except (ConnectionResetError, ConnectionRefusedError): - print("Connection Problems. I'll keep on trying to connect") - time.sleep(5) - self.client = Client(host=self.host, port=self.port) - - def request(self, msg): - try: - with self.client_lock: - answer = self.client.request(msg) - print(f'Received {answer} for {msg} at {pylsl.local_clock()}') - return answer - except (ConnectionResetError, ConnectionRefusedError): - print("Connection Problems. Retry") - return None - - def trigger(self, id: str): - marker = '{"single_pulse":"COIL_' + id + '"}' - try: - with self.client_lock: - self.client.send(marker) - except (ConnectionResetError, ConnectionRefusedError): - print("Connection Problems. Aborting for safety") - return None - - tstamp = pylsl.local_clock() - print(f'Pushed {marker} at {tstamp}') - with self.outlet_lock: - self.outlet.push_sample([marker], tstamp) + self.verbose = verbose - return tstamp - - def push_marker(self, marker:str): - - tstamp = pylsl.local_clock() - - print(f'Pushed {marker} at {tstamp}') - - with self.outlet_lock: - - self.outlet.push_sample([marker], tstamp) - - -# %% - - -class Client(object): - """ - A LocaliteJSON socket client used to communicate with a LocaliteJSON socket server. - - example - ------- - host = '127.0.0.1' - port = 6666 - client = Client(True) - client.connect(host, port).send(data) - response = client.recv() - client.close() - - example - ------- - response = Client().connect(host, port).send(data).recv_close() - """ - - socket = None - - def __init__(self, host, port=6666, timeout=None): - self.host = host - self.port = port - self.timeout = timeout + def push(self, marker: Dict[str, Any] = {"command": "ping"}, + tstamp: float = None): + "connects, sends a message, and closes the connection" + self.connect() + self.write(marker, tstamp) + self.close() def connect(self): - 'connect wth the remote server' - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.socket.connect((self.host, self.port)) - self.socket.settimeout(self.timeout) + "connect wth the remote server" + self.interface = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.interface.connect((self.host, self.port)) + self.interface.settimeout(1) + + def write(self, marker, tstamp): + "encode message into ascii and send all bytes" + msg = json.dumps((marker, tstamp)).encode("ascii") + if self.verbose: + print(f"Sending {marker} at {tstamp}") + self.interface.sendall(msg) def close(self): - 'closes the connection' - self.socket.shutdown(1) - self.socket.close() - del self.socket - - def write(self, data): - self.socket.sendall(data.encode('ascii')) - return self - - def read_byte(self, counter, buffer): - """read next byte from the TCP/IP bytestream and decode as ASCII""" - if counter is None: - counter = 0 - char = self.socket.recv(1).decode('ASCII') - buffer.append(char) - counter += {'{': 1, '}': -1}.get(char, 0) - return counter, buffer - - def read(self): - 'parse the message until it is a valid json' - counter = None - buffer = [] - while counter is not 0: - counter, buffer = self.read_byte(counter, buffer) - response = ''.join(buffer) - return self.decode(response) - - def listen(self): - self.connect() - msg = self.read() - self.close() - return msg + "closes the connection" + self.interface.shutdown(1) + self.interface.close() - def decode(self, msg: str, index=0): - # catches and interface error - msg = msg.replace('reason', '\"reason\"') - try: - decoded = json.loads(msg) - except json.JSONDecodeError as e: - print("JSONDecodeError: " + msg) - raise e - key = list(decoded.keys())[index] - val = decoded[key] - return key, val +def available(port: int = 6667, host: str = "127.0.0.1", verbose=True) -> bool: + """test whether a markerserver is already available at port - def send(self, msg: str): - self.connect() - self.write(msg) - self.close() + args + ---- - def request(self, msg="coil_0_amplitude"): - self.connect() - msg = '{"get":\"' + msg + '\"}' - self.write(msg) - key = val = '' - _, expected = self.decode(msg) - logger.debug(msg) - logger.debug(expected) - while key != expected: - key, val = self.read() - self.close() - return None if val == 'NONE' else val + host: str + the ip of the markerserver (defaults to localhost) + port: int + the port number of the markerserver (defaults to 6667) -# c = Client(host=host) -# %timeit -n 1 -r 1000 c.request() -# 5.22 ms ± 3.29 ms per loop (mean ± std. dev. of 1000 runs, 1 loop each) -if __name__ == "__main__": + returns + ------- - client = get_client(host="134.2.117.173") - + status: bool + True if available, False if not + """ + c = Client(host=host, port=port) + try: + c.push({"command": "ping"}, local_clock()) + return True + except ConnectionRefusedError as e: + if verbose: + print(e) + print(f"Markerserver at {host}:{port} is not available") + return False diff --git a/localite/clients/__init__.py b/localite/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/localite/coil.py b/localite/coil.py index 0cff8a1..fa31761 100644 --- a/localite/coil.py +++ b/localite/coil.py @@ -5,7 +5,7 @@ @author: Robert Guggenberger """ -from client import get_client +from localite.client import get_client from json import dumps as _dumps # %% diff --git a/localite/mitm.py b/localite/mitm.py new file mode 100644 index 0000000..d5d4468 --- /dev/null +++ b/localite/mitm.py @@ -0,0 +1,200 @@ +from pylsl import StreamInfo, StreamOutlet +import pylsl +import socket +import threading +import queue +import json +from time import sleep +import pkg_resources + + +def myip() -> str: + """returns a string with the computers default IP address + """ + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + + +# ----------------------------------------------------------------------------- +def _read_msg(client): + "receive byte for byte to read the header telling the message length" + # parse the message until it is a valid json + msg = bytearray(b" ") + while True: + try: + prt = client.recv(1) + msg += prt + # because the first byte is b' ' + marker, tstamp = json.loads(msg.decode("ascii")) + print(f"Received {marker} for {tstamp} at {pylsl.local_clock()}") + return marker, tstamp + except json.decoder.JSONDecodeError: + pass + except Exception as e: + print(e) + break + + return ("", None) + + +class ManInTHeMiddle(threading.Thread): + """Main class to manage the LSL-MarkerStream as man-in-the-middle + + when started, it automatically checks whether there is already a MarkerServer + running at that port. If this is the case, it returns and lets the old one + keep control. This ensures that subscribers to the old MarkerServer + don't experience any hiccups. + """ + + def __init__( + self, + own_host: str = "127.0.0.1", + own_port: int = 6667, + localite_host: str = "127.0.0.1", + localite_port: int = 6666, + ): + self.localite_host = localite_host + self.localite_port = localite_port + self.own_host = own_host + self.own_port = own_port + self.is_running = threading.Event() + self.is_running.clear() + + def stop(self): + "stop the server" + self.is_running.clear() + + def run(self): + """wait for clients to connect and send messages. + + This is a Thread, so start with :meth:`server.start` + """ + + # we check whether there is already an instance running, and if so + # let it keep control by returning + if available(self.port): + self.singleton.clear() + if self.verbose: + print("Server already running on that port") + self.is_running.set() + return + else: + self.singleton.set() + if self.verbose: + print("This server is the original instance") + + # create the MarkerStreamer, i.e. the LSL-Server that distributes the strings received from the Listener + markerstreamer = _MarkerStreamer(name=self.name) + markerstreamer.start() + # create the ListenerServer, i.e. the TCP/IP Server that waits for messages for forwarding them to the MarkerStreamer + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.settimeout(1) + listener.bind((self.host, self.port)) + listener.listen(1) + if self.verbose: + print( + "Server mediating an LSL Outlet opened at {0}:{1}".format( + self.host, self.port + ) + ) + self.is_running.set() + while self.is_running.is_set(): + try: + client, address = listener.accept() + try: + marker, tstamp = _read_msg(client) + if marker.lower() == "ping": # connection was only pinged + print("Received ping from", address) + elif marker.lower() == "poison-pill": + print("Swallowing poison pill") + self.is_running.clear() + break + else: + markerstreamer.push(marker, tstamp) + except socket.timeout: + print("Client from {address} timed out") + finally: + client.shutdown(2) + client.close() + except socket.timeout: + pass + + print(f"Shutting down MarkerServer: {self.name}") + markerstreamer.stop() + + +def create_outlet(name: str = "localite_markers") -> [StreamOutlet, StreamInfo]: + """Create a Marker StreamOutlet with the given name. + + Raise an ConnectionAbortedError if the source_id is already in use somewhere else + + """ + source_id = "_".join((socket.gethostname(), name)) + info = StreamInfo( + name, + type="Markers", + channel_count=1, + nominal_srate=0, + channel_format="string", + source_id=source_id, + ) + + info.desc().append_child_value("software", "localite TMS Navigator 4.0") + info.desc().append_child_value("stimulator", "MagVenture") + info.desc().append_child_value("streamer", str( + pkg_resources.get_distribution("localite"))) + + if pylsl.resolve_byprop("source_id", source_id, timeout=3): + raise ConnectionAbortedError( + f"There is already a localiteLSL with the same source_id {source_id} running" + ) + outlet = StreamOutlet(info) + return outlet, info + + +class MarkerStreamer(threading.Thread): + "publishes whatever is in its queue as a MarkerStream" + + def __init__(self, name: str = "localite_marker"): + threading.Thread.__init__(self) + self.queue = queue.Queue(maxsize=0) # indefinite size + self.is_running = threading.Event() + self.name = name + + def push(self, marker: str = "", tstamp: float = None): + if marker == "": + return + if tstamp is None: + tstamp = pylsl.local_clock() + self.queue.put_nowait((marker, tstamp)) + + def stop(self): + self.queue.join() + self.is_running.clear() + + def await_running(self): + print("[", end="") + while not self.is_running.is_set(): + print(".", end="") + sleep(0.5) + print("]") + + def run(self): + outlet, info = create_outlet(name=self.name) + self.is_running.set() + print("Starting MarkerStreamer") + print(info.as_xml()) + while self.is_running.is_set(): + try: + marker, tstamp = self.queue.get(block=False) + outlet.push_sample([marker], tstamp) + self.queue.task_done() + print( + f"Pushed {marker} from {tstamp} at {pylsl.local_clock()}") + except queue.Empty: + sleep(0.001) + print(f"Shutting down MarkerStreamer: {self.name}") diff --git a/requirements.txt b/requirements.txt index 49568c3..dff92d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -json -socket \ No newline at end of file +pylsl >= 1.13 \ No newline at end of file diff --git a/setup.py b/setup.py index 120dc31..8352396 100644 --- a/setup.py +++ b/setup.py @@ -1,35 +1,41 @@ +import setuptools from distutils.core import setup +from pathlib import Path +with (Path(__file__).parent / "readme.md").open("r") as f: + long_description = f.read() setup( - name='ctrl-localite', - version='0.0.3', - description='Control Magventure with LocaliteJSON', - long_description='Toolbox to control a Magventure TMS with localites JSON-TCP-IP Interface', - author='Robert Guggenberger', - author_email='robert.guggenberger@uni-tuebingen.de', - url='https://github.com/pyreiz/ctrl-localite', - download_url='https://github.com/pyreiz/ctrl-localite', - license='MIT', - packages=['localite'], - entry_points={'console_scripts': [ - 'localiteLSL=localite.clients.lsl_client:main', - 'localiteMock=localite.test.mockserver:mock'], + name="localite", + version="0.1.0", + description="Stream from and control TMS using Localite", + long_description=long_description, + author="Robert Guggenberger", + author_email="robert.guggenberger@uni-tuebingen.de", + url="https://github.com/pyreiz/ctrl-localite", + download_url="https://github.com/pyreiz/ctrl-localite", + license="MIT", + packages=setuptools.find_packages(), + entry_points={ + "console_scripts": [ + "localite-stream=localite.cli:main", + "localite-mock=localite.mock:main", + ], }, classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'Intended Audience :: Education', - 'Intended Audience :: Healthcare Industry', - 'Intended Audience :: Science/Research', - 'Intended Audience :: Information Technology', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Topic :: Scientific/Engineering :: Human Machine Interfaces', - 'Topic :: Scientific/Engineering :: Medical Science Apps.', - 'Topic :: Software Development :: Libraries', - ] + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Healthcare Industry", + "Intended Audience :: Science/Research", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Human Machine Interfaces", + "Topic :: Scientific/Engineering :: Medical Science Apps.", + "Topic :: Software Development :: Libraries", + ], ) diff --git a/localite/test/mockserver.py b/test/mockserver.py similarity index 100% rename from localite/test/mockserver.py rename to test/mockserver.py diff --git a/localite/test/set_response.py b/test/set_response.py similarity index 100% rename from localite/test/set_response.py rename to test/set_response.py diff --git a/test/test_streamer.py b/test/test_streamer.py new file mode 100644 index 0000000..99279b4 --- /dev/null +++ b/test/test_streamer.py @@ -0,0 +1,28 @@ +from localite.mitm import MarkerStreamer, create_outlet +from pylsl import resolve_streams +from pytest import fixture +import pkg_resources +import time + + +@fixture +def ms(capsys): + ms = MarkerStreamer() + yield ms + ms.stop() + time.sleep(1) # shut down within one second + out, err = capsys.readouterr() + assert "Shutting down" in out + + +def test_outlet(ms, capsys): + t0 = time.time() + ms.start() + ms.await_running() + assert (time.time()-t0) < 5 # start within 5 seconds + out, err = capsys.readouterr() + assert "localite_marker" in out + assert "Markers" in out + v = str( + pkg_resources.get_distribution("localite")) + assert f"{v}" in out