From 3e688d6971f79191ba65fe2122bd163e63ccf3b7 Mon Sep 17 00:00:00 2001 From: "simon.johnson" Date: Wed, 17 Nov 2021 10:30:21 +0000 Subject: [PATCH] Initial commit --- .gitignore | 31 + LICENSE | 201 +++++ README.md | 122 +++ appspec.yml | 18 + buildspec.yml | 21 + ec2-scripts/create_run_script.sh | 10 + ec2-scripts/run.sh.template | 33 + ec2-scripts/start.sh | 2 + ec2-scripts/stop.sh | 2 + ec2-scripts/testing_build_triggers.sh | 7 + ext/todo.txt | 3 + images/mqttsn-arch.png | Bin 0 -> 179362 bytes images/waves-1400px.png | Bin 0 -> 319094 bytes images/waves-400px.png | Bin 0 -> 166130 bytes mqtt-sn-client/README.md | 72 ++ mqtt-sn-client/dependency-reduced-pom.xml | 66 ++ mqtt-sn-client/pom.xml | 103 +++ .../client/MqttsnClientConnectException.java | 45 + .../slj/mqtt/sn/client/impl/MqttsnClient.java | 716 ++++++++++++++++ .../impl/MqttsnClientMessageHandler.java | 53 ++ .../impl/MqttsnClientRuntimeRegistry.java | 54 ++ .../client/impl/MqttsnClientUdpOptions.java | 37 + .../impl/cli/ClientInteractiveMain.java | 44 + .../impl/cli/MqttsnInteractiveClient.java | 494 +++++++++++ .../cli/MqttsnInteractiveClientLauncher.java | 53 ++ .../mqtt/sn/client/impl/examples/Example.java | 109 +++ .../slj/mqtt/sn/client/spi/IMqttsnClient.java | 171 ++++ .../spi/IMqttsnClientRuntimeRegistry.java | 31 + .../sn/client/spi/IMqttsnClientService.java | 35 + .../sn/client/test/ClientConnectionTest.java | 188 +++++ mqtt-sn-codec/README.md | 28 + mqtt-sn-codec/pom.xml | 61 ++ .../java/org/slj/mqtt/sn/ExampleUsage.java | 59 ++ .../java/org/slj/mqtt/sn/MqttsnConstants.java | 100 +++ .../java/org/slj/mqtt/sn/PublishData.java | 58 ++ .../mqtt/sn/codec/AbstractMqttsnCodec.java | 77 ++ .../codec/AbstractMqttsnMessageFactory.java | 200 +++++ .../mqtt/sn/codec/MqttsnCodecException.java | 43 + .../org/slj/mqtt/sn/codec/MqttsnCodecs.java | 39 + .../org/slj/mqtt/sn/spi/IMqttsnCodec.java | 112 +++ .../sn/spi/IMqttsnIdentificationPacket.java | 6 + .../org/slj/mqtt/sn/spi/IMqttsnMessage.java | 60 ++ .../mqtt/sn/spi/IMqttsnMessageFactory.java | 392 +++++++++ .../org/slj/mqtt/sn/wire/MqttsnWireUtils.java | 90 ++ .../sn/wire/version1_2/Mqttsn_v1_2_Codec.java | 251 ++++++ .../Mqttsn_v1_2_MessageFactory.java | 391 +++++++++ .../payload/AbstractMqttsnMessage.java | 129 +++ .../AbstractMqttsnMessageWithFlagsField.java | 171 ++++ .../AbstractMqttsnMessageWithTopicData.java | 72 ++ ...tractMqttsnPublishMessageConfirmation.java | 50 ++ .../payload/AbstractMqttsnSimpleMessage.java | 43 + .../AbstractMqttsnSubscribeUnsubscribe.java | 74 ++ .../payload/AbstractMqttsnWillMessage.java | 60 ++ .../AbstractMqttsnWillTopicMessage.java | 77 ++ .../payload/AbstractMqttsnWillresp.java | 46 + .../version1_2/payload/MqttsnAdvertise.java | 82 ++ .../version1_2/payload/MqttsnConnack.java | 60 ++ .../version1_2/payload/MqttsnConnect.java | 138 +++ .../version1_2/payload/MqttsnDisconnect.java | 73 ++ .../version1_2/payload/MqttsnEncapsmsg.java | 148 ++++ .../wire/version1_2/payload/MqttsnGwInfo.java | 89 ++ .../version1_2/payload/MqttsnPingreq.java | 88 ++ .../version1_2/payload/MqttsnPingresp.java | 56 ++ .../wire/version1_2/payload/MqttsnPuback.java | 86 ++ .../version1_2/payload/MqttsnPubcomp.java | 43 + .../version1_2/payload/MqttsnPublish.java | 107 +++ .../wire/version1_2/payload/MqttsnPubrec.java | 43 + .../wire/version1_2/payload/MqttsnPubrel.java | 43 + .../wire/version1_2/payload/MqttsnRegack.java | 86 ++ .../version1_2/payload/MqttsnRegister.java | 125 +++ .../version1_2/payload/MqttsnSearchGw.java | 69 ++ .../wire/version1_2/payload/MqttsnSuback.java | 90 ++ .../version1_2/payload/MqttsnSubscribe.java | 47 ++ .../version1_2/payload/MqttsnUnsuback.java | 66 ++ .../version1_2/payload/MqttsnUnsubscribe.java | 45 + .../version1_2/payload/MqttsnWillmsg.java | 42 + .../version1_2/payload/MqttsnWillmsgreq.java | 42 + .../version1_2/payload/MqttsnWillmsgresp.java | 42 + .../version1_2/payload/MqttsnWillmsgupd.java | 42 + .../version1_2/payload/MqttsnWilltopic.java | 45 + .../payload/MqttsnWilltopicreq.java | 42 + .../payload/MqttsnWilltopicresp.java | 42 + .../payload/MqttsnWilltopicudp.java | 46 + .../version1_2/payload/MqttsnWireTests.java | 364 ++++++++ mqtt-sn-core/ext/create-keystore.sh | 52 ++ mqtt-sn-core/pom.xml | 66 ++ .../mqtt/sn/cli/AbstractInteractiveCli.java | 402 +++++++++ .../AbstractMqttsnBackoffThreadService.java | 116 +++ .../sn/impl/AbstractMqttsnMessageHandler.java | 623 ++++++++++++++ .../impl/AbstractMqttsnMessageRegistry.java | 117 +++ .../AbstractMqttsnMessageStateService.java | 775 +++++++++++++++++ .../mqtt/sn/impl/AbstractMqttsnRuntime.java | 341 ++++++++ .../impl/AbstractMqttsnRuntimeRegistry.java | 421 +++++++++ .../mqtt/sn/impl/AbstractMqttsnTransport.java | 190 +++++ .../sn/impl/AbstractMqttsnUdpTransport.java | 64 ++ .../sn/impl/AbstractRationalTopicService.java | 21 + .../sn/impl/AbstractSubscriptionRegistry.java | 61 ++ .../mqtt/sn/impl/AbstractTopicRegistry.java | 214 +++++ .../mqtt/sn/impl/MqttsnContextFactory.java | 43 + .../sn/impl/MqttsnMessageQueueProcessor.java | 152 ++++ .../impl/ram/MqttsnInMemoryMessageQueue.java | 145 ++++ .../ram/MqttsnInMemoryMessageRegistry.java | 83 ++ .../MqttsnInMemoryMessageStateService.java | 123 +++ .../MqttsnInMemorySubscriptionRegistry.java | 110 +++ .../impl/ram/MqttsnInMemoryTopicRegistry.java | 199 +++++ .../mqtt/sn/model/AbstractContextObject.java | 52 ++ .../org/slj/mqtt/sn/model/IContextObject.java | 38 + .../org/slj/mqtt/sn/model/IMqttsnContext.java | 30 + .../mqtt/sn/model/IMqttsnSessionState.java | 46 + .../slj/mqtt/sn/model/INetworkContext.java | 40 + .../slj/mqtt/sn/model/InflightMessage.java | 76 ++ .../slj/mqtt/sn/model/MqttsnClientState.java | 29 + .../org/slj/mqtt/sn/model/MqttsnContext.java | 63 ++ .../org/slj/mqtt/sn/model/MqttsnOptions.java | 797 ++++++++++++++++++ .../sn/model/MqttsnQueueAcceptException.java | 45 + .../slj/mqtt/sn/model/MqttsnSessionState.java | 98 +++ .../slj/mqtt/sn/model/MqttsnWaitToken.java | 98 +++ .../mqtt/sn/model/QueuedPublishMessage.java | 134 +++ .../sn/model/RequeueableInflightMessage.java | 34 + .../org/slj/mqtt/sn/model/Subscription.java | 77 ++ .../java/org/slj/mqtt/sn/model/TopicInfo.java | 81 ++ .../org/slj/mqtt/sn/net/MqttsnTcpOptions.java | 293 +++++++ .../slj/mqtt/sn/net/MqttsnTcpTransport.java | 561 ++++++++++++ .../org/slj/mqtt/sn/net/MqttsnUdpOptions.java | 197 +++++ .../slj/mqtt/sn/net/MqttsnUdpTransport.java | 174 ++++ .../org/slj/mqtt/sn/net/NetworkAddress.java | 112 +++ .../mqtt/sn/net/NetworkAddressRegistry.java | 189 +++++ .../org/slj/mqtt/sn/net/NetworkContext.java | 80 ++ .../spi/IMqttsnConnectionStateListener.java | 85 ++ .../mqtt/sn/spi/IMqttsnContextFactory.java | 54 ++ .../spi/IMqttsnInstrumentationProvider.java | 15 + .../mqtt/sn/spi/IMqttsnMessageHandler.java | 74 ++ .../slj/mqtt/sn/spi/IMqttsnMessageQueue.java | 85 ++ .../sn/spi/IMqttsnMessageQueueProcessor.java | 28 + .../mqtt/sn/spi/IMqttsnMessageRegistry.java | 25 + .../sn/spi/IMqttsnMessageStateService.java | 160 ++++ .../mqtt/sn/spi/IMqttsnPermissionService.java | 52 ++ .../sn/spi/IMqttsnPublishFailureListener.java | 38 + .../spi/IMqttsnPublishReceivedListener.java | 32 + .../sn/spi/IMqttsnPublishSentListener.java | 34 + .../IMqttsnQueueProcessorStateService.java | 28 + .../org/slj/mqtt/sn/spi/IMqttsnRegistry.java | 35 + .../mqtt/sn/spi/IMqttsnRuntimeRegistry.java | 71 ++ .../org/slj/mqtt/sn/spi/IMqttsnService.java | 34 + .../sn/spi/IMqttsnSubscriptionRegistry.java | 88 ++ .../slj/mqtt/sn/spi/IMqttsnTopicRegistry.java | 64 ++ .../mqtt/sn/spi/IMqttsnTrafficListener.java | 56 ++ .../org/slj/mqtt/sn/spi/IMqttsnTransport.java | 54 ++ .../mqtt/sn/spi/INetworkAddressRegistry.java | 43 + .../org/slj/mqtt/sn/spi/MqttsnException.java | 43 + .../spi/MqttsnExpectationFailedException.java | 43 + .../mqtt/sn/spi/MqttsnRuntimeException.java | 43 + .../mqtt/sn/spi/MqttsnSecurityException.java | 19 + .../org/slj/mqtt/sn/spi/MqttsnService.java | 54 ++ .../mqtt/sn/spi/NetworkRegistryException.java | 19 + .../org/slj/mqtt/sn/utils/MqttsnUtils.java | 175 ++++ .../org/slj/mqtt/sn/utils/StringTable.java | 263 ++++++ .../slj/mqtt/sn/utils/StringTableWriters.java | 272 ++++++ .../java/org/slj/mqtt/sn/utils/TopicPath.java | 218 +++++ .../mqtt/sn/utils/TransientObjectLocks.java | 116 +++ mqtt-sn-gateway-paho-connector/README.md | 2 + .../dependency-reduced-pom.xml | 65 ++ .../mqtt-sn-gateway-paho-connector.iml | 21 + mqtt-sn-gateway-paho-connector/pom.xml | 120 +++ .../AggregatingGatewayInteractiveMain.java | 58 ++ .../gateway/impl/AggregatingGatewayMain.java | 85 ++ .../impl/PahoMqttsnBrokerConnection.java | 224 +++++ .../PahoMqttsnBrokerConnectionFactory.java | 43 + mqtt-sn-gateway/pom.xml | 71 ++ .../gateway/cli/MqttsnInteractiveGateway.java | 351 ++++++++ .../cli/MqttsnInteractiveGatewayLauncher.java | 53 ++ .../mqtt/sn/gateway/impl/MqttsnGateway.java | 121 +++ .../impl/MqttsnGatewayRuntimeRegistry.java | 114 +++ .../AbstractMqttsnBrokerConnection.java | 48 ++ .../broker/AbstractMqttsnBrokerService.java | 141 ++++ .../MqttsnAggregatingBrokerService.java | 133 +++ .../MqttsnGatewayAdvertiseService.java | 73 ++ .../gateway/MqttsnGatewayMessageHandler.java | 299 +++++++ .../MqttsnGatewayPermissionService.java | 39 + ...ttsnGatewayQueueProcessorStateService.java | 40 + .../gateway/MqttsnGatewaySessionService.java | 365 ++++++++ .../mqtt/sn/gateway/spi/ConnectResult.java | 39 + .../mqtt/sn/gateway/spi/DisconnectResult.java | 15 + .../MqttsnInvalidSessionStateException.java | 45 + .../mqtt/sn/gateway/spi/PublishResult.java | 39 + .../mqtt/sn/gateway/spi/RegisterResult.java | 57 ++ .../org/slj/mqtt/sn/gateway/spi/Result.java | 95 +++ .../mqtt/sn/gateway/spi/SubscribeResult.java | 76 ++ .../sn/gateway/spi/UnsubscribeResult.java | 43 + .../spi/broker/IMqttsnBrokerConnection.java | 46 + .../IMqttsnBrokerConnectionFactory.java | 30 + .../spi/broker/IMqttsnBrokerService.java | 47 ++ .../spi/broker/MqttsnBrokerException.java | 45 + .../spi/broker/MqttsnBrokerOptions.java | 140 +++ .../IMqttsnGatewayAdvertiseService.java | 34 + .../IMqttsnGatewayRuntimeRegistry.java | 41 + .../IMqttsnGatewaySessionRegistryService.java | 67 ++ .../spi/gateway/MqttsnGatewayOptions.java | 103 +++ 198 files changed, 21211 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 appspec.yml create mode 100644 buildspec.yml create mode 100644 ec2-scripts/create_run_script.sh create mode 100644 ec2-scripts/run.sh.template create mode 100644 ec2-scripts/start.sh create mode 100644 ec2-scripts/stop.sh create mode 100644 ec2-scripts/testing_build_triggers.sh create mode 100644 ext/todo.txt create mode 100644 images/mqttsn-arch.png create mode 100644 images/waves-1400px.png create mode 100644 images/waves-400px.png create mode 100644 mqtt-sn-client/README.md create mode 100644 mqtt-sn-client/dependency-reduced-pom.xml create mode 100644 mqtt-sn-client/pom.xml create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/MqttsnClientConnectException.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClient.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientMessageHandler.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientRuntimeRegistry.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientUdpOptions.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/ClientInteractiveMain.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/MqttsnInteractiveClient.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/MqttsnInteractiveClientLauncher.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/examples/Example.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClient.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClientRuntimeRegistry.java create mode 100644 mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClientService.java create mode 100644 mqtt-sn-client/src/test/java/org/slj/mqtt/sn/client/test/ClientConnectionTest.java create mode 100644 mqtt-sn-codec/README.md create mode 100644 mqtt-sn-codec/pom.xml create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/ExampleUsage.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/MqttsnConstants.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/PublishData.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/AbstractMqttsnCodec.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/AbstractMqttsnMessageFactory.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/MqttsnCodecException.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/MqttsnCodecs.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnCodec.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnIdentificationPacket.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessage.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageFactory.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/MqttsnWireUtils.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/Mqttsn_v1_2_Codec.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/Mqttsn_v1_2_MessageFactory.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessage.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessageWithFlagsField.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessageWithTopicData.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnPublishMessageConfirmation.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnSimpleMessage.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnSubscribeUnsubscribe.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillMessage.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillTopicMessage.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillresp.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnAdvertise.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnConnack.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnConnect.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnDisconnect.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnEncapsmsg.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnGwInfo.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPingreq.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPingresp.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPuback.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubcomp.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPublish.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubrec.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubrel.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnRegack.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnRegister.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSearchGw.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSuback.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSubscribe.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnUnsuback.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnUnsubscribe.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsg.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgreq.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgresp.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgupd.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopic.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicreq.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicresp.java create mode 100644 mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicudp.java create mode 100644 mqtt-sn-codec/src/test/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWireTests.java create mode 100644 mqtt-sn-core/ext/create-keystore.sh create mode 100644 mqtt-sn-core/pom.xml create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/cli/AbstractInteractiveCli.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnBackoffThreadService.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageHandler.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageStateService.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnRuntime.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnRuntimeRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnTransport.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnUdpTransport.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractRationalTopicService.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractSubscriptionRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractTopicRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/MqttsnContextFactory.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/MqttsnMessageQueueProcessor.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageQueue.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageStateService.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemorySubscriptionRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryTopicRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/AbstractContextObject.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IContextObject.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IMqttsnContext.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IMqttsnSessionState.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/INetworkContext.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/InflightMessage.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnClientState.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnContext.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnOptions.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnQueueAcceptException.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnSessionState.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnWaitToken.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/QueuedPublishMessage.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/RequeueableInflightMessage.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/Subscription.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/TopicInfo.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnTcpOptions.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnTcpTransport.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnUdpOptions.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnUdpTransport.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkAddress.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkAddressRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkContext.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnConnectionStateListener.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnContextFactory.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnInstrumentationProvider.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageHandler.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageQueue.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageQueueProcessor.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageStateService.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPermissionService.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishFailureListener.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishReceivedListener.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishSentListener.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnQueueProcessorStateService.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnRuntimeRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnService.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnSubscriptionRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTopicRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTrafficListener.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTransport.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/INetworkAddressRegistry.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnException.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnExpectationFailedException.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnRuntimeException.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnSecurityException.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnService.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/NetworkRegistryException.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/MqttsnUtils.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/StringTable.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/StringTableWriters.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/TopicPath.java create mode 100644 mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/TransientObjectLocks.java create mode 100644 mqtt-sn-gateway-paho-connector/README.md create mode 100644 mqtt-sn-gateway-paho-connector/dependency-reduced-pom.xml create mode 100644 mqtt-sn-gateway-paho-connector/mqtt-sn-gateway-paho-connector.iml create mode 100644 mqtt-sn-gateway-paho-connector/pom.xml create mode 100644 mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/AggregatingGatewayInteractiveMain.java create mode 100644 mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/AggregatingGatewayMain.java create mode 100644 mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/PahoMqttsnBrokerConnection.java create mode 100644 mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/PahoMqttsnBrokerConnectionFactory.java create mode 100644 mqtt-sn-gateway/pom.xml create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/cli/MqttsnInteractiveGateway.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/cli/MqttsnInteractiveGatewayLauncher.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/MqttsnGateway.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/MqttsnGatewayRuntimeRegistry.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/AbstractMqttsnBrokerConnection.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/AbstractMqttsnBrokerService.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/MqttsnAggregatingBrokerService.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayAdvertiseService.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayMessageHandler.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayPermissionService.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayQueueProcessorStateService.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewaySessionService.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/ConnectResult.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/DisconnectResult.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/MqttsnInvalidSessionStateException.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/PublishResult.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/RegisterResult.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/Result.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/SubscribeResult.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/UnsubscribeResult.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerConnection.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerConnectionFactory.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerService.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/MqttsnBrokerException.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/MqttsnBrokerOptions.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewayAdvertiseService.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewayRuntimeRegistry.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewaySessionRegistryService.java create mode 100644 mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/MqttsnGatewayOptions.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b0e5d763 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +.idea +/.idea/.gitignore +/.idea/compiler.xml +/.idea/jarRepositories.xml +/.idea/misc.xml +/.idea/modules.xml +/.idea/uiDesigner.xml +/.idea/vcs.xml +codebuild_build.sh +artifacts/ +target/ +/mqtt-sn-gateway-vertx/mqtt-sn-gateway-vertx.iml +/mqtt-sn-gateway-artefact/mqtt-sn-gateway-artefact.iml +/mqtt-sn-gateway/mqtt-sn-gateway.iml +/mqtt-sn-core-sec/mqtt-sn-core-sec.iml +/mqtt-sn-core/mqtt-sn-core.iml +/mqtt-sn-codec-netty/mqtt-sn-codec-netty.iml +/mqtt-sn-codec/mqtt-sn-codec.iml +/mqtt-sn-client/mqtt-sn-client.iml +/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/TMain.java +/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/LMain.java +/mqtt-publisher/ +/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/LTCPMain.java +/mqtt-sn-core/ext/my-keystore.jks +/mqtt-sn-core/ext/my-truststore.jks +/mqtt-sn-gateway-artefact/src/main/java/org/slj/mqtt/sn/gateway/impl/TcpGatewayMain.java +mqtt-sn-core-sec/ +mqtt-sn-client-sec/ +/client.properties +/config.properties +/gateway.properties diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 00000000..7401a313 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ + +# MQTT For Small Things (SN) +MQTT-SN is an optimized version of the MQTT specification designed for use on small, low powered, sensor devices, often running on the edge of the network; typical of the IoT. + +View the intial [MQTT-SN Version 1.2](http://www.mqtt.org/new/wp-content/uploads/2009/06/MQTT-SN_spec_v1.2.pdf) specification written by **Andy Stanford-Clark** and **Hong Linh Truong** from **IBM**. + +### MQTT-SN Evolved +As of late 2020, the MQTT technical committee at OASIS (via a sub-committee led by **Ian Craggs** ([Ian's Blog](https://modelbasedtesting.co.uk))) are working on standardisation and changes to bring MQTT-SN more in line with MQTT version 5. +This is an ongoing piece of work which we hope to formalise and conclude in 2021. + +### Project Goals +Noteable open-source works already exists for various MQTT and MQTT-SN components, the mains ones of note are listed below; many fall under the eclipse PAHO project. The work by **Ian Craggs** et al on the MQTT-SN Java gateway set out the wire transport implementation and a reference transparent gateway. That is a gateway which connects a client to a broker side socket and mediates the access. My goal of this project and its work are that it should provide an open-source **aggregating gateway** implementation, and should implement the wire messages such that the next interation of MQTT-sn can be demonstrated using this project. + +### MQTT / MQTT-SN differences +The SN variant of MQTT is an expression of the protocol using smaller messages, and an optimised message lifecycle. All the features of a core MQTT broker are available to the SN clients, with the gateway implementation hiding the complexities of the protocol using various multiplexing techniques. An SN client has no need of a TCP/IP connection to a broker, and can choose to any transport layer; for example UDP, BLE, Zigbee etc. + +### Project modules +Module | Language & Build | Dependencies | Description +------------ | ------------- | ------------- | ------------- +[mqtt-sn-codec](/mqtt-sn-codec) | Java 1.8, Maven | **Mandatory** | Pure java message parsers and writers. Includes interfaces and abstractions to support future versions of the protocol. +[mqtt-sn-core](/mqtt-sn-core) | Java 1.8, Maven | **Mandatory** | Shared interfaces and abstractions for use in the various MQTT-SN runtimes +[mqtt-sn-core-sec](/mqtt-sn-core-sec) | Java 1.8, Maven | Optional | DTLS implementation of the transport layer to add a secure datagram socket to a broker & client runtime **NOTE: This project is still work in progress will in the short term fail compilation** +[mqtt-sn-client](/mqtt-sn-client) | Java 1.8, Maven | Client | A lightweight client with example transport implementations. Exposes both a simple blocking API and an aysnc publish API to the application and hides the complexities of topic registrations and connection management. +[mqtt-sn-gateway](/mqtt-sn-gateway) | Java 1.8, Maven | Gateway | The core gateway runtime. The end goal is to provide all 3 variants of the gateway (Aggregating, Transparent & Forwarder) where possible. I have started with the aggregating gateway, since this is the most complex, and the most suitable for larger scale deployment. +[mqtt-sn-gateway-artefact](/mqtt-sn-gateway-artefact) | Java 1.8, Maven | Optional | Simple runtime implementation of UDP aggregating gateway with a shaded jar build +[mqtt-sn-codec-netty](/mqtt-sn-codec-netty) | Java 1.8, Maven | Optional | Binding of the codecs into Netty codecs for use in a Netty runtime + +### Quick start - Gateway + +Git checkout the repository. For a simple standalone jar execution, run the following maven deps. + +```shell script +mvn -f mqtt-sn-codec clean install +mvn -f mqtt-sn-core clean install +mvn -f mqtt-sn-gateway clean install +mvn -f mqtt-sn-gateway-artefact clean package +``` + +This will yield a file in your mqtt-sn-gateway-artefact/target directory that will be called mqtt-sn-gateway-.jar. You can then start a broker +from a command line using; + +```shell script +java -jar /mqtt-sn-gateway-.jar >> /mqtt-sn-gateway-udp.log 2>&1 +``` + +Running the executable jar will run the code specified in AggregatingGatewayMain. + +Click into [mqtt-sn-gateway-artefact](/mqtt-sn-gateway-artefact) for more details on the gateway. + + +### Quick start - Client + +Ensure you have the requisite projects mounted per the table above. You can then run the main method for in the Example.java located in the project. You can see the configuration options for details +on how to customise your installation. + +Click into [mqtt-sn-client](/mqtt-sn-client) for more details on the client. + +### Configuration + +The default client/gateway behaviour can be customised using configuration options. Sensible defaults have been specified which allow it to all work out of the box. +Many of the options below are applicable for both the client and gateway runtimes. + +Options | Default Value | Type | Description +------------ | ------------- | ------------- | ------------- +contextId | NULL | String | This is used as either the clientId (when in a client runtime) or the gatewayId (when in a gateway runtime). **NB: This is a required field and must be set by the application.** +maxWait | 10000 | int | Time in milliseconds to wait for a confirmation message where required. When calling a blocking method, this is the time the method will block until either the confirmation is received OR the timeout expires. +maxTopicLength | 1024 | int | Maximum number of characters allowed in a topic including wildcard and separator characters. +threadHandoffFromTransport | true | boolean | Should the transport layer delegate to and from the handler layer using a thread hand-off. **NB: Depends on your transport implementation as to whether you should block.** +handoffThreadCount | 5 | int | How many threads are used to process messages recieved from the transport layer +discoveryEnabled | false | boolean | When discovery is enabled the client will listen for broadcast messages from local gateways and add them to its network registry as it finds them. +maxTopicsInRegistry | 128 | int | Max number of topics which can reside in the CLIENT registry. This does NOT include predefined alias's. +msgIdStartAt | 1 | int (max. 65535) | Starting number for message Ids sent from the client to the gateways (each gateway has a unique count). +aliasStartAt | 1 | int (max. 65535) | Starting number for alias's used to store topic values (NB: only applicable to gateways). +maxMessagesInflight | 1 | int (max. 65535) | In theory, a gateway and broker can have multiple messages inflight concurrently. The spec suggests only 1 confirmation message is inflight at any given time. (NB: do NOT change this). +maxMessagesInQueue | 100 | int | Max number of messages allowed in a client's queue. When the max is reached any new messages will be discarded. +requeueOnInflightTimeout | true | boolean | When a publish message fails to confirm, should it be requeued for DUP sending at a later point. +predefinedTopics | Config| Map | Where a client or gateway both know a topic alias in advance, any messages or subscriptions to the topic will be made using the predefined IDs. +networkAddressEntries | Config | Map | You can prespecify known locations for gateways and clients in the network address registry. NB. The runtime will dynamically update the registry with new clients / gateways as they are discovered. In the case of clients, they are unable to connect or message until at least 1 gateway is defined in config OR discovered. +sleepClearsRegistrations | true | boolean | When a client enters the ASLEEP state, should the NORMAL topic registered alias's be cleared down and reestablished during the next AWAKE or ACTIVE states. +minFlushTime | 1000 | int | Time in milliseconds between a gateway device last receiving a message before it begins processing the client queue +discoveryTime | 3600 | int | The time (in seconds) a client will wait for a broadcast during CONNECT before giving up +pingDivisor | 4 | int | The divisor to use for the ping window, the dividend being the CONNECT keepAlive resulting in the quotient which is the time (since last sent message) each ping will be issued +maxProtocolMessageSize | 1024 | int | The max allowable size (in bytes) of protocol messages that will be sent or received by the system. **NB: this differs from transport level max sizes which will be determined and constrained by the MTU of the transport** +### Runtime + +You can hook into the runtime and provide your own implementations of various components or bind in listeners to give you control or visibility onto aspects of the system. + +#### Traffic Listeners + +You can access all the data sent to and from the transport adapter by using traffic listeners. + +```java + MqttsnClientRuntimeRegistry.defaultConfiguration(options). + withTransport(new MqttsnClientUdpTransport(udpOptions)). + withTrafficListener(new IMqttsnTrafficListener() { + @Override + public void trafficSent(INetworkContext context, byte[] data, IMqttsnMessage message) { + System.err.println(String.format("message [%s]", message)); + } + + @Override + public void trafficReceived(INetworkContext context, byte[] data, IMqttsnMessage message) { + System.err.println(String.format("message [%s]", message)); + } + }). + withCodec(MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2); +``` + +### Related people & projects +Our goal on the [MQTT-SN technial committee](https://www.oasis-open.org/committees/tc_home.php?wg_abbrev=mqtt) is to drive and foster a thriving open-source community. Listed here are some related open-source projects with some comments. + +Project | Author | Link | Description +------------ | ------------- | ------------- | ------------- +Paho Mqtt C Client | Various | [GitHub Repository](https://github.com/eclipse/paho.mqtt.c) |Fully featured MQTT C client library +Paho Mqtt C Embedded | Various | [GitHub Repository](https://github.com/eclipse/paho.mqtt.embedded-c) | Fully featured embedded MQTT C client library +Paho Mqtt-Sn C Embedded | Various | [GitHub Repository](https://github.com/eclipse/paho.mqtt-sn.embedded-c) | C implementation of a transparent MQTT-SN gateway, client and codecs +Mqtt-sn Transparent Java Gateway | Ian Craggs & jsaak | [GitHub Repository](https://github.com/jsaak/mqtt-sn-gateway) | Java implementation of a transparent MQTT-SN gateway, c + +### Aggregating gateway diagram + +![MQTT-SN Aggregating Gateway Architecture](/images/mqttsn-arch.png) + diff --git a/appspec.yml b/appspec.yml new file mode 100644 index 00000000..97980927 --- /dev/null +++ b/appspec.yml @@ -0,0 +1,18 @@ +version: 0.0 +os: linux +files: + - source: / + destination: /home/ubuntu/mqtt-sn-gateway-udp +hooks: + ApplicationStop: + - location: stop.sh + timeout: 30 + runas: root + AfterInstall: + - location: create_run_script.sh + timeout: 30 + runas: ubuntu + ApplicationStart: + - location: start.sh + timeout: 30 + runas: root diff --git a/buildspec.yml b/buildspec.yml new file mode 100644 index 00000000..77a12637 --- /dev/null +++ b/buildspec.yml @@ -0,0 +1,21 @@ +version: 0.2 + +phases: + install: + runtime-versions: + java: openjdk8 + build: + commands: + - echo Build started on `date` + - mvn -f mqtt-sn-codec clean install + - mvn -f mqtt-sn-core clean install + - mvn -f mqtt-sn-gateway clean install + + - mvn -f mqtt-sn-gateway-artefact clean package + +artifacts: + files: + - mqtt-sn-gateway-artefact/target/mqtt-sn-gateway-1.0.0.jar + - ec2-scripts/* + - appspec.yml + discard-paths: yes diff --git a/ec2-scripts/create_run_script.sh b/ec2-scripts/create_run_script.sh new file mode 100644 index 00000000..900109d5 --- /dev/null +++ b/ec2-scripts/create_run_script.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +export mqtt_broker=$(aws ssm get-parameter --name /MQTT_SN_Gateway/iots/mqtt_broker --region eu-west-1 | jq -r '.Parameter.Value') +export thing_id=$(aws ssm get-parameter --name /MQTT_SN_Gateway/iots/thing_id --with-decryption --region eu-west-1 | jq -r '.Parameter.Value') +export thing_user=$(aws ssm get-parameter --name /MQTT_SN_Gateway/iots/thing_user --with-decryption --region eu-west-1 | jq -r '.Parameter.Value') +export thing_password=$(aws ssm get-parameter --name /MQTT_SN_Gateway/iots/thing_password --with-decryption --region eu-west-1 | jq -r '.Parameter.Value') + +cd "$(dirname "${BASH_SOURCE[0]}")" +envsubst '${mqtt_broker},${thing_id},${thing_user},${thing_password}' < run.sh.template > /home/ubuntu/mqtt-sn-gateway-udp/run.sh +chmod +x /home/ubuntu/mqtt-sn-gateway-udp/run.sh diff --git a/ec2-scripts/run.sh.template b/ec2-scripts/run.sh.template new file mode 100644 index 00000000..e851aa0d --- /dev/null +++ b/ec2-scripts/run.sh.template @@ -0,0 +1,33 @@ +#!/bin/bash + +export MQTTSN_HOME=/home/ubuntu/mqtt-sn-gateway-udp + +# Get system RAM in KB +totalMemKB=$(awk '/MemTotal:/ { print $2 }' /proc/meminfo) + +# Set default memFlags just in case... +memFlags="-Xmx128M -Xms64M" + +# t3.nano (512MB RAM) +if ((${totalMemKB}<=512000)); then + memFlags="-Xmx128M -Xms64M" +# t3.micro (1GB RAM) +elif ((512001<=${totalMemKB} && ${totalMemKB}<=1024000)); then + memFlags="-Xmx512M -Xms128M" +# t3.small (2GB RAM) +elif ((1024001<=${totalMemKB} && ${totalMemKB}<=2048000)); then + memFlags="-Xmx1G -Xms256M" +# t3.medium (4GB RAM) +elif ((2048001<=${totalMemKB} && ${totalMemKB}<=4096000)); then + memFlags="-Xmx2G -Xms256M" +# t3.large and above (8GB+ RAM) +elif ((${totalMemKB}>=4096001)); then + memFlags="-Xmx2G -Xms256M" +fi + +echo "System RAM KB: ${totalMemKB}" +echo "Setting memFlags: ${memFlags}" + +cd $MQTTSN_HOME + +/usr/bin/java ${memFlags} -jar /home/ubuntu/mqtt-sn-gateway-udp/mqtt-sn-gateway-1.0.0.jar 2442 ${thing_id} ${mqtt_broker} 1883 ${thing_user} ${thing_password} >> /home/ubuntu/mqtt-sn-gateway-udp/logs/mqtt-sn-gateway-udp.log 2>&1 diff --git a/ec2-scripts/start.sh b/ec2-scripts/start.sh new file mode 100644 index 00000000..f7b93581 --- /dev/null +++ b/ec2-scripts/start.sh @@ -0,0 +1,2 @@ +#!/bin/bash +systemctl start mqtt-sn-gateway-udp.service diff --git a/ec2-scripts/stop.sh b/ec2-scripts/stop.sh new file mode 100644 index 00000000..b2d97a43 --- /dev/null +++ b/ec2-scripts/stop.sh @@ -0,0 +1,2 @@ +#!/bin/bash +systemctl stop mqtt-sn-gateway-udp.service diff --git a/ec2-scripts/testing_build_triggers.sh b/ec2-scripts/testing_build_triggers.sh new file mode 100644 index 00000000..6c518bec --- /dev/null +++ b/ec2-scripts/testing_build_triggers.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Testing the CodeBuild commit message filter. +# First *without* the message regex... [BUILD] +# Trying again with the commit message regex +# WebHook test for CodePipeline after manual creation of JSON webhook. +# CodePipeline approval test via SNS diff --git a/ext/todo.txt b/ext/todo.txt new file mode 100644 index 00000000..eb29a8eb --- /dev/null +++ b/ext/todo.txt @@ -0,0 +1,3 @@ + +7. Will implementation +8. Forwarder packet implementation \ No newline at end of file diff --git a/images/mqttsn-arch.png b/images/mqttsn-arch.png new file mode 100644 index 0000000000000000000000000000000000000000..3cc662994c5e9aded4ab380c2d94fce91f9465fb GIT binary patch literal 179362 zcmeFZ^;=Z^*EWoufQ3lw1*m|OGzgd=(%qp*4xJJMCMXh0H!9r{Lo)*^Al(c-Aks0C z0}S!5?e)Fy<9+^s=ZEJwzFe0F%nW<)&t9Ju=XtKRd8eWzOL>z1Bn1TprQE~&Y7`Vl z$0#U{s2o2E?^IaFJHWTY7IzfyP*4-h`? zg)1Kg#heKRg?Kat1+7DTh3YN%;OG;1+4~gx$bX-z)5GAM6Alk`ohc|T#UcL=$hp1> zhc}P8$SFQJ_WRhSb7uqsZ)s4%87buM-_dwBxUlQt9X^dF?&B~-GS8I*PtG3adrwhx zX+req$xKJ<_%bmm{#jlt=8}2A-)vSBYizDz-J-d1k>2yKoV!v^v^k!?RS8-Qt>>%g-cPsHJZ?x1)_ouEwN?HC5BjhG*}= z*Fjj`zh7t0eH>ryci&P_{qKw8R}PB)?~BW?kB|NDi|1$l`R_r9 zHU4Xe|MNra8xL@3%?O zpB8cgKQ*kCZ}$#&&6T2^9LL{I&pEF*OU`Jay!^M19lgMCJ~`#bVfG&lA(t2p@=#?{ z^YV`r{#0U@nWejQ4*yzy)-&9d*6d2z)bvQ(Mj?=mLqg!*i4%?ifv0ah_9MCmyV()F z+_`txghfR&w(Bo8mn^iLKA50C?7QeOw6Zh7Vo1BhV;EIid&iZumestZVj8A+WJ@eG zrE1uwuA$Da((CWa4;)5PeDs+%!wgMZbeNgGw?U3@-o8qQX`69QZ)5lLZppDzW8#&J z+xL3;T?Z;<7n(D~yynH42r!o1b2Ora`Ke#3mf- zETZL^*JwSmUcSveRPF22r55I@z18wuTH>?G;jz+HQ6%M-9{ShbI_Nx~^|iI5yJxm{ z>MHm#6U)!m_TJIUUlt0L)?mWV7SUonCqqwYFLrfzXP0tP2UZv-WgAD3sk2kA8^5P* zJ3*gEN5v$TX(m5K__JqR=2B;`|3267)vH&@8)rq7ykw*3T1E}|+ldw{O939^OTS!~ zpWX8GGLF!*SxjCUG9zkD?tQQD?d_UB8Fy&bc|An5?XOXpc23d259jsnIlng#SK1gF z?e3n}PCcja^-nHbSyy+~8a1yWkE%?q=J6^WeKv2nyIj#br4fXf({^p0L`GY(SmJVW z)}EtRC8eZzw8{r!Or=KgsTH2htHnJ}TVwe(2V&?7B`SJ*y5_X$qC!Jc#EQ~F`t4v3 zry|;`KEJqa5yfih`m1hm`#JH@Fr#a-hJCQ`;(YE6oz2L|*qq|QAyvpaR-ID!a~ zNi+*X%0F%O&{&&rX=xm}2|k&BJ)?uipyC|gGb>9wy6$C3$!eeRp}?+ltx5XkM^Q55 zAVp!#C25-_{PY|?Q-HR`fm7v(jYVo37K%aRC zyEZZ+8Je1O2`|sQJR&U(li3NgZG+u_ z;DFOQwRqV1QLGf@>C>D->>e!#EMReZLlWVUv0v7T!=LZIegFQ%Go_G*Sp$o%OVY%XDnv)BV)JB2 zmc`)l4)K1nMKS3$vHyrT{vpvmIS4BiRk1)-SO4R>5WAR(gsE7R;7(=BB>klV0&L3d zvSkqs_^u#YE*3N8KWw^x_&qotk9_BFX(z|U`feQf#J!6gP3T9n^;!wowj;Q_m! zgWAj?4L4I~V6PPuZZ)3vsaP0cRg&eT^ex-3*C%dkaz;eZOX| z6OsV8E+778J$AfMa7WxFi~ilM;EK`)q3CyL&+2~Y==YcKlwPM5{yj+gaq}1%~s2E zG2d4_ld=grH6q|1>hb{xOCUY%NL@81oRMaUVN6D zS1FJ2;(SS;2%<^V4q_&oSLI}b6Z(h*Nzo@bPft(H@_mO6A58z?`cNq;#KRho>K*L+ z+_by*SrHP<=;nx*&om1o<7rR(pv)(-6{W0VDs{SgG6}8kW+c%s&9G{Ka2ZQ!5 ztxJ)f-8yhD3XbTC*X?dGm2vtrbE0__QsW5DI&-oLidsText@oMd)=)fG{LdD^2um) z{dc^6#IY8)s&?ByhR1TJhS_mmadBapn#~tuXkwEMz4)*ViX1%j>(QHK{b#wJP&0|q zImDy!ertJcOt$e8zn-wkP4o8Zj`~C9*!~&oh1s~rb%TfrOK*=Gk9D8aiK-qtw{SGJ zskCp}q5w0pedKLVcnbBBdKjVoN0!<9oxJZ7f=bHr1#7F|rMOQ6B9E5DX@*amB zpcwx_P&)ucc=n0PsOG?@ts2ig1%gU0` zo7|MXKXCJxxWWmzKJ^S2IH1 z+W`%%>|3p~0uAA--_IHg?wYfn=iv$Pkn zy(x>2>HfoX>j`CT-zZKEYR{ubk2=s?Q9AVlmEzgusjW^1de()2dpqd=M0^HlDF5L@Y-kEkC94U;?x91G(~WZPOvT;rdRI+u>7+AP(X_+}85R`qi)%Vx znF&WjZGiM-WR=+%D`Yws{W6^W)RbP|3vrj~RtEBh=rBU8 zDo@n9R6|3Z-p0GYKn+J#tnowIg-rl3xQT1DwA*i7SPA}CKD$uL^_S%2;A8gQp3r|* zcSoL}w4vsMO?)(?A)V_6D}r709yxVzR=?bdKZ}2`x~E&&sjhO7c2d7S?Xq-(eeRu! z3NJ51pWS62oQ#Z&^;9v(9pd7{dhrNrWo7#sp~2HoMW)*Cdg?X3onT~Pn8$DE+><7d zW{WfHA_GPt<0^8t2oMedezhJqENTs?&YqY@w&E6Zh)4Ux#1kR+K9_1UhCC%3TOuF1 zx65;#l*>P2@8Yud7H_5Tm3|@EuBiIUOM_+p8jEw_<}&r2_ChmiuFs@*CoUfnAMr3r;h?Cb_b* z?N8Fz?|z;u1JWRv?6UH9S{a-dYK*{4My`EV)k+NBke_W!nSYKvz6&=a?yrzQw9|Y2@|z1h|#ZJ zhryZ#y#K-ebVe*a_+Ak!*eveu37U|b+$MTi7^g3}xz0VG&%|x#%t3VsB?vl!NM>C{{|3iHKNoPyE=ourfJP^0?}8c!7D82!SHCy;s`Wq;CjM z`)ByY!8-&}Wj?Z@2C_@$tCWr@W8?SE6PL(;!5eqm zM58G9$0WUqi%ao}a}oFWRw4VU8LHBAWUG82AUIe{Z_a@xwLsauRA_9~hu6(bY;D8K zKQHr%)7)>lNCLae8)yG^m+-m=N3O8Qf{`w3{96$p1JjFTInFTe>)~t3kP%w1AJ~3u zmOJ-5sxfHe1=>BtwE}nhMaz9tg?mwu5&HWt6@6*x7?}JMce7I)*0PirZQ*wc;$FVD z8S}@GXm#VWM(dV*q}MqHQ_9MU$^@T=K#Nv!0@;48IgS|XP*3jh%JHuIp;HPMKAO|V z@csn_{=5yR7Kg)TBvdO14}i8aYXk3-{lvz{B$#sS>+tJMjZI$9G9@mlnrqdm&_92`>tr)9 za-pHTJjt2eM56Qhd=Q~}8lN=0pkQX6l3CCw{Hxs&LV41nKEnZPfH$ElT|PVwcB*NW z`8PBhyy&rA9APbEV?Sn{r#3>rcndr{C5(BhruNcoF&Toucs)EH5+=@plViDFp-zPP zMOH&D{j05q42CkbxN1YX@nZ-W$^q`Wy zbbD2&7g!fy^f@EEvuh9L4op}2M2Zl|4A*}z=N?`W>e@yfCGc?w33;^4&(v)lK3gwt z6e)G|I2Gl35O?IXz$5wMKY0UFs|>0ILd>QK%)%WPf`OBniVJ^eaY6BEuKQq&H7%Y# z{T4^dXC^a265f;{04x~9o?qp#+$zT zm=gD&eQp@~elkg}sNJ=`q0YJ$-P}rOT$GBidC?Nf-|J1FqiGxcY&cNNfxo5o*MR@Y zZ4DBaI9r-~&n9dPE$u#b_=@Rdv)UWJDPQLB=jRFZ=BU{LX%)adp3~H_H--SiH(XE{ zv8=DKtL4O{CLU{SUDC7JiwLG{cq=5UcIznB$R*f~eLP=l%1CD9AO=v|R zYP;RR})|iKO`zoiF$<$buP)zrilNAg(x_CmCUFQ;1f{^=v!+D*w&d>s|<9 z_1Ry#1K3>0N46LcI-L5Ik~Bvn5pAhqL`6k4_iSAkw~NM?L8Wj^N*O0*x|3nM4D(QY zq;iOFkNpLoc|&=xEhFL;V$yz9eDSGCVZ&X?5_Z)T+30qSUmA8R}Dq zu(-G$1lJ8bDq2;~$q+>bEK4DBh~}?0_6NvwiMJ@AZ90db+}jAhc`|#j+I=iz^4>UW zgHl|CYi*8R$xLL14?{NWi)Z%A^R24(UVFduqnc7!^YF+(j{wQ<;5+pEZ!f(EvExR& z>`Be!8J;66REHHPWj*vK=@Ra z3kvic@C&N=*?`J6*Q%y8#e%>f5?TDh$ra}tL~bilZ_j2olO9t@7nm9p{m;rK3%%_P za6c^oWgba^1vi(}flCAI%L+K@w42rvky1+1)U6*NS?o}2HmvWxi+xT^s^nTKyw!fZ z&^(Q(kv2MCLl=O4Iq~J?t!8qj)BfY;OOQ}Fd3gG4ra|9>GLVsR8NX8L1r-d)V7=Sl zU}(^;hwpsjG&H|;6Ukclg04z%-2((Zt1Wk40i?+#V~ooW%rCnK1G;pG@b>)0!>=Z3 zDc1=%nZ|Y4+1WQWor7e-qf?blPiUg?uPZdP?WGb83^G8OIU?y+*L8%oMAG)r^4kVq z$VqtbT9Yl| zZ?kCri7vw_zpgKDaQkZcv5{mWXY&tZ7CB%|nJN)dzMiMyqI_3%GuI8g^hI~=mTt5Z ziB0A00uwg3+BbDl3Ut(U&`-@%4VJG@#6#f)E?Bs5erDGhC?C#*TO~Dvv~7Qs7$ABN zt9-;9Di^`4vyz%ClBYw`-58w&xq%x$I}&W41XOf)8)-ZZ2V@|PrJC1Ka{?nVL>F(< zv;RsWKbK}y(WXUtf`VjCkKa>It(S6U&u7iGA{=Y)*@(4JY4NO+;VU-Xtdzil>pV^P z7V0$bDlJe)mr+p5@o!@#zQyVvB?x^6LAWTVSIDBz#Vjp7v${xLn?5!oGBoGq4&&sc zIhSz#wlXW>&*hspyKZ4KQ#HAB7b7Pc&Itx--th@WsFhN0yf$c_TT%-0ihYnNVUM?} zr-P*SfxiLDRRe+AFxYO=+=5eDL|<%xF3bKjw#^VBLq3*o<{08!_nssGdV3_Vs5Kid z7b~2q^YP=ySlHW*U>@B10d^};qd?Pa?Cbly*`IAXT`4mZbZ%kvsQ$D=Rp5Mkb1S0Y zIF)Z7!j^)c1Md5oAKH9|i77WEr(8Q~Zf>`}ye#83a1QfNGY)H;K@;C9&AG(HRH5|V zY4DJleM&Pm35kaeQ(O{v$IVVD4MHIzEP_t3W+m5d)o8!<%uN67+FyfY?W{bkDlLzp zYE-p8q8FlWe)_-2gsKP}SOK|gJ+Hn)V{h0D|qd-EQ4J`_?ibRJr7jY=H8#K_pQJSV`)>?;X5 zR2CgVNkx?fLWUTl*VYYMuo%cr@`3{)3XOKX5rxJ){-!LGRHLQizCaiK#VBpkbK zA%qV2XPNq-XtyCa`XGJT#Xb5bxoL6Xn5F~k3E{=7c1&3HeR?f!{R@#vTyV89y|Xn) ziAx3-v9_~PSC`M;y5b8GNS9jZy^=OsyWo#^vmj+=+H8lPO>aj1E2Z8ENs=rQ%;^^K zyv5wxE27)yDezi=v5F1^!*yUz$Erl2h6?6;*K_kTvC3SuTx``9NIMq~9dwg#|Mtoq zWTAmFx9F-{Qdd_OC>CEnI$`ezu)5sfP^4{Xx91zy+1Xjlt&o7=3frvRrNe;9AiYjG zr)|RELCyUK2w4Bm*XxeO{dI)v{QEU__5WzB&-Cf0zz4$x2u>MaLYnw?&i|Sf?tA#o z-(>lpL+@UHVrgl4eK_GiuaV!S)%a_lf8V)s|Nnp8GY1e&<=?L}cmFm?{(W)#_`gQ| z_tk$7`j3tNYl#0E;=iHsA0POyA^xu#qV)O3hFgM|vkVMwjV}cx)z;SP7aIS2$(q~% z7}0vpS2SQ_ZD$wBRz9-i;NI{JK+m@~r?PT$*lvC=`_1AP?VLQukGvWTgFQb=PjE&19j%iRkzBK(}b(ai8-5_ zn-1O zgkwc+(FoD}jvoqXF!ebu?6L9`&al1coUO2U;q2MZp`quHGcLNteZB(MUB{G`&i|@= zKP8AiapFWQziGW|XurnKQj_3wpD)@cr+*|DDaa78ac+E8^VTiUJmG8VWGK z64Sd<`i=H2ob$N@D{z&utIpyHg+q%?ZEaL~TW(t`X0Sj`Ztlgjjn+x0%<^(w;kZ_! z5bNvr0RcA;-3J(l0^znqBiF*hg6rC~25+gqTV-ch=J2@1)~L+kj-jEUkEWe^ngT!9 z_^@5+-28k@FoLbzZy}Pg-Ob98yo`+Nu)1U3)t^7#dH3!eCkIFQEc$&&h_<6+0o?N| zt#33JFM{3S|IxL*0~F6Y5s;df*XF&%fE7znN{~Y9xw^VKl^dAa;iKv5ma_q8~$_nHwL^n3|S$ zG{O3&q+|jT2m0aP$N5;&Q&OTs$Yk>FHxXiojbPYSMKu{2zpW~`W8T*SeXpD=M{r^>%=ewBI=BvEUJ`DhNrrR~ysi_a(lRL)k&COvW zcGUe^*4A0D7u(z0-^Ah}Ec_cL3KXbknWDjpx*Oaa9Lr>hM(`v99_+k95xGQmN z@)Urjn-~CBev?GP?{@!vW8d>&k{m`Vf8_GmzM-M)o}Qjh>dJ1et{l9)yfgCd9A3*how zGr5^xRaG_ed8?OU$=kQbanj%Y{rp@u7Ie!;?Bu>1mvL}%u1Ltjh7D-R%KA?duW@j2 zOra7I5+2FRpCWyLWl}u816iU*@wZcXctixv9}Mgg?o+cqIBdt)9I%Xi)v}+NzM^6f z?2(3+RvNo}) z-LxwmDpZC|@bmNA!S0-AV3>E0?=(_V3oRe1Joh)Hbn_iy2NL-fU^P1%+hK28OUs9V zs_%qIx-IB*;Nj9QmE>Zgqb;{SwQ1YfD#I9K)ghO+9IJWyvNlD2zQ&LP!ChS;Naj~Gu8*jL; z)Ptj>Q+G!XFm@{gXdhET^!Xaai!?MeYZ#MWOXd*Sj~`#`_m>RVBqy%^ZVbC)yqaij zU1h+752A;GWCzNB{`u!dgavHN)I>@On|wxJXD5FePWDo~Qo1z?)jADP?}KXfJm!;g z{uJWZbbtL?7gBNu9wsX~7HfkUtgIj1larI{z{{K*3(U+!Y;82ZxJW6E0m~j|AvP5u z@#lE|{%dI0-uSHhiL=b4Dm_(6K|w=K?wwgcQ(_|XDv<`Ou8t57clv*$^T&_-VvNC` z@{eR+hm2FIK>E$i%4*tk#%`)5=8iE#=0EMn55LOY|2D`248`-@`SY#PgkwjKy3BU5 zuVTO>C#R>gzkcN#TkA_#PSVoUj2QLZ-+k`O`z|0r4fe@+^Y0|!w&$vcmX_B4nS+o^ zC$v3}umhiswE9x@Kt?85M@+<`E&fx!V$4nUnF}gP>CQi9KUY*%9`dt+P=eTgq@+ZJ zS2;-W{4&sW?p|Vbb#>$!CHlNjE^h8#Fnz17U8%mLq-3byevY*JQv76ePR@(4M}&DUzPIw|9qwQs@&?(@7WcLs((g zIgMOBOq0Eron4+*j?UR)X~c()Iv^qT@Zp~!JB2$m3=9nQozMoXg-n=TR@U!5Iy8jJ zR1tWVe*)jzYvz%gpWlA-jhVN1m9b`({bpozbVgNGm4mSq?86y1gsctUm+9~A>>Mx6 z)4O~3B{=s>B~|F@ndy45cg$nK>NIq8n;~R~9>~Q<&?8U_CY`o4%PA=-W9I9i8kTAH z4~nlZeGY?34`W}`(wNv)?MrcImeENgbEa|e@i~oQ*W$WW2cbGkxbw_}m*XhCgpls+Eqy7+iz@Sa79H$-^Icy>`Y5alq5IGr*G_|C(Bu8FnB^|XV~;p_f73X zN##2EwnOesor_g|0;8iF8e06qSALPD;*Oiffn~b7)SMs9zIbtt-=x}xv z78dpl4rT=hQ<<5YZ!`~3B042huAy@?5xhc(nBO z<8(cYjg9l+E}HisFF^7JC;{31Xzky|%B#Qrc0*WL3&0sz)!NER)yD_Br3c-mm6esA z;o+Q~9*qaGgdORupby+aLcR9rvdGAbknRzPkWg`s;%=(<)KkgbO#hZ?WuKKhy&|P^ zL%cf*?Y>0X!Q#ge%tpQ;Y#cD%wd^S3qi0b0J+mkL$GrW+?-4#?y0aox?byKCbFvKi z1#9h#8yO7)w4wZT%KCPtvWH&168W9zKNoBFlqEkxl~P1hbf#Oo)GQX2d~meiT%*=Q zyB^j6(f(l!o`s+|{_-z!<;#Meop^X+qTPv#n>TOv3=Cv~F~K+^Ygh~hBNuw|R%Z?ht5!`s($3;Xz49Y+6u(33{w}IYNrYh<1|2%^dxo2*$!dV^^?$QuvVu^ z$?aYSVvMG)vtGPM)@<>mEv5XXBJ0))>8w z)BToPP&|qD^)lQYEJ3Hw(c9=5spbWtqOw!?@VAmkXk#N)9=QsOMn?I%rnW^ zl&ZJd9#`vn=okhwbLU^j$7<3{p~+3o(kieV-u-!jNN4H&YGX^gfeVtWYu8#9?H43d z7W0?r)<=V*h_v2;(&W@}>3vp+;13@@y?%YzS+A+2L|ERe%KZ`osB2h^4UbV=J`HKF zbsY~Si5rD|aR0t#7Yv#WEzS{%xXfLc2+}~Ivac$}KFxFhD5qv?o680$CuB~9;&DW5 zXvQ%u?oc*NvFp*{=~5zNU$9|>Ua4n6qSsWn-u`EHCl?8fEm>(mO0<5-Va|SUncxcB z7pWbN>h{yjLVdMrF5=Ca+M$vAu4$6A*X3Tk+bhYLB&KWZ^UHM3X>DeUxO>-kHDsSh zPVbg)8rv+jtdj2bn44Fz7x4;2ZOr&kI&O^#aUT}L(!0il6*Rtw8AW8E9ExoL*j7Gc#R=m9yg*C<6t=W^ay>_-qA-vcp14QJ~ zV0biSgV}j~sm$VVw#9zU%!zc~Xfm7@y@cYOx!=m3t6!}%uf?kcTcYfVF8LXPUju?mMH+<$N|d@TZ#ueLvH5o=2fdJqDpeXjjjl;JNIx zep-*HC@C`w3Oc=)cG-bBom+J@HBBD2BT>#HyX;W;`5;0z8-M=%@O}IP@B(M&A~VjW z{(c>(kgQ|$+#*O?ZE9-TIamTg3`qw;e+F?>6m}(Dq2q*@^!C2kUggoiu_wF!k=L(r z8M41Aj}@xEui4O^Rb8YHK|APMrsqm2EU&5S^rWf;S0O9Cy=A|)Xd#sh(V!CQ+FE|Z zIW11p0z_0K=pcA2Nz7fQzAKN5^yY?BkIHq);&VT+8DhJB;k6)6W5qBJhm1#(p`Ptz z4V9qhd0QLm#iaJetc$E$MH^nBB16Q@Mt=Ta^lj^%ke7G@y903Q{F!ibE$BW%-cXV{6QL7M@}+y-FuZ`lgJ@ z&(c%&ox8%FD6KBxra9MA=P95w5NJ{yH6+9Mk?VG^^bxk}V=q=bnG1xpIVykVd6%-9b6+L;SPW zn8CsalabRgbeS+r61xvw&4;0S5*0V5`CdwVPqM304sH3=QrQZ1QiiGMjdQwwnp#V9 zBWz>_)6%NMG38j(`4QX>^+q=8s~kPmWa3`WJ@cBU@W6pr`bc328yGx{O)<&Ibpfeg zX5I~hk&nT}bP}T!xgy!bD*UsD0xz$y7<)g!P<|&}6=LsnA{?T#mww5=u}=9Wh-?aH zQXT!{McO$=q=A|T`CmBCFb^U%K}y2gcRo&^-4LBWa-EV=n^5%Pn^4vm82LHs-VtDO zk1s&G4e-(Y%49Pr87fv*vt>gwOG{ev&42s@>yKH;nDmW|PE8-pQ_w4MC>b5~Rk&u&GaEDQb6pNee@puOgwwJcnws*VW|V@) zGM|3wiI{TCrI9_{k#7D-)|;MV`FWZBB!xwrEllmR zT~Y4K2W#QrX1DsW-d#T!Ni1UsJk!Y+NoEf4Is8PyQLNM^fPg9=m<{zKlycFgqdXpz zZ?H(EHn8ZL9{u#dV?eLOvLq`wsdupVee316GNQ=0nwsapc2mZ2VQlQ|TZ>`g;VA%x ziXP=$(M2DcZ&4z$yVG!X}or#m1sLz)R4 z85evNN7f2lJaw_Ux=)I1^y+c(-tG?G3yE1P4!P~7Zr`jmcnBqpZLXkQ(qcfGo4v7W z%A|g~Fcgj?b8=RenY-wETWwgZ zzVHKpIu)o54I~o*z(SHFEL>rH4pTUghb=vI@+6307uUgu`ugdU(aG1^XGiXR@8;(*^+@3y4& zN=zuleLd9OOhZYlg5X=*cWu@DwJn)idw!k3q@nky>urSLsCRmqU|wy;KdX5k7&3hk zi?X37XQege9_Oj4pWCgM4Q1BBJnZUi;`?6duM|5W(N|L69BsEYt1OuU;7Bgit=jeS z%--i2Sq+U8DE--awO31rD*Up~Ub@D9IzQiB!OHV<$sI6aD+g!LVuP}A2V~(XuQeIa zEpKKIvvY89am`@q=;+L8cA!0+VZ4KgTFA6l&Fd+*%HE+Ni8Qo?gv9p|Ly9sUH-B{m z2O*`CZMG=H_PA*EV;FQ&Mz8O8JM-@J6r%eAL!Y-B?~mLO;48qm&LM?fRw+J=4awbI z4we=RNGJ`FL*y#NgOXi@Y=+czX&kH{ni`4|R*_u4K72}=NqtOREjs(xH^yBjl9#xu zbEADi*~cBvcByr=%6i}QoJfA>@zKDqj>UU)WCj{n@apQ7`RXYT>o((pz>zp2qwr9+ znD2w35~5)G5F<+8^zkr>)@O&S*)5p`+pXz&N>h3XTq|@B$rp8kp7^mR{5}os z)C`%INIxnyG3O??J|f#mUh8x$pqVV=J} zbs`6c^KBuo*t$}jZ^dTKJx4hKh(e6i@lxys-uFGYktwyW4lJriJCXb%k|#QKDbdK8 zvV@Yv8zIPOj$kPyDEI$xM6}1-BoYOZba<_v8RnInsUwhnH0M^1*?aT2UbQ&k95wK% z$*TGbA46S8-v%Om=;p)drdREdd;^0*x5~`R0U~kCT4#t{ggryh-wm{Zk*b|mhi#c} zrga`1I*{W4@!RF8mpCmmva|n~?azEK?yy$O zHj6BFe0Vm}GzrS1=UQ!V)-8v-fIj*lk}aWBIJ1~4@5;| z!vMtzkt!SHz0D}UFXi3-&!llYrpQYXwQCUUNXpz#wOMn1N*LNl_C-8k{FuHsn1* z!1L~1DU%y>b8|giT~DOifBx(Mkw`Y)uP{4XK8nY%3!VWg&&|mRJT<8z>&vcwjyxQ60Y1!G$-UccvpB5Hu%$`1-0fbWVZ23!UY^)#%Q*{ju&CvMC z%Tt0!D9kZ&&6A<6=3#=jFqq~^H3I{KGF}i9p`G(rdp)mVZ=au)o!x+D?0#^d<_Bc9 zsN|tca8amsew7l@HSl5UI$^3U0d=C?)5{C$(S!Ak4bFf5xeJXe&g<7z?%a7X*(@=t zZUV!+8O6me-hYP^mMFZ1r>ZnBEvkQ;99I0k#kd0 zet6%4whDC75L1tgjKqq&W?j6Y^9gi3!>VVh=}HNOuWcM19Us9kX+H>+6we?3dm<|T ziP&HeNqcK*ZX;Hvu=@vgPKkjI5FZG!a-q$WU770XE^w=pQ&ZMU!{tpaEgHaqKt9(r zGUAaFcl`AloaeV&CA-|4H*d^`xVgEfkf}h5?_a);fdBFNfZhnwB;LCU_!J0gWxQ}F z!)&&;wo?GhFr+=hIpA8rj5g!|J#kckc$s%l>u9GdhN(l|{QsuX;>$ak05{fCZ{2JEf4 z?_L#5(z0=gh-h21Cq{f9@94=?*Mx|Qj*kA+AbbD*^($BIczAfsEiP&Tp%j;p&{R<| z(du<|cjw~eRsQ%T)PIUl+)D6xp>ApxhH1v4*CM`AaYzhYbwRtY%yO(j=p74W4Z zCn*?;rR6bDk&&s-sEy+_)oX(O65<5Nn*gLh7H6Z>9)R8SBRj$VJl8*nXzAxw+}%m8 zOwyvF*Fp7z{NiAIk|JLZl|5WZUw)}%~f}6 zSl28Gd1_*Eo67%Qm&5wd&erO^o0P7w2{#16;b)J?^?W8?>bCpt1i=cXnaAgEeSEJF}d(D+C z6EF(4*6vGw{cFOHYXO#648PPX)Xydda9{=?2oXPeN_sCs%+(yY{J?0RMUo&_r#GlV z-7Ec;`6ZtpAW~35M)^~0Vq|CvC{~Cxcb${dsb>RfemGmM!L5d_63C07P+O18)xkig z3LG-@3GcJ^TdcYN0${E^np5@O(mrdegy@M9aApQP4~e04(C?z;>M-?gy)Ecjp3inA z&3)N3^J>%JL~~H5VJ4H=A)d_&&gr?JER|!VjYDLi){vY|gk9lr0BS;0c%_&5xuXRP zyBzw>1LZ;f$P)9d8*U#`j}x(;{3Z5CPDwC*duKx+sbE3fbteZo19AvN|8A@FUZoh- zD1P%-sT2#}Lo72is=c8IFs{rM14V;|N?p$D?CflH(4sYACn(A$kR};lZJ9ydhvEqb zQJD4oaqKCDoxIGSb$4TLgP7UOLEqou$Td!3D9vkb4mxr)zT4i)Cs_jt6-Vdw-C6>g zn*khK;F$+_&F-SRQDL#EyC|@!!9t#dk@3IKac@I{ch(rUCRf5&EeX6xVGs!Aulb#W(-pOx|zVoTmh!2s7#JM*rH=4EeG=yzK zVc^zt&kOg}pz^F!aQ))O41>P!7gz_P&{>V@C# z+q1#?O4_!_p+)}u?owmKfxt^-L9a%1TS#t&67i9jTQly)i)3p|^9W_+)tVm-kYP#m zSn}T{4Ed&7B<}sd;Fi%ar}0;tV-9F4y~;GPhbkWs|2afwuJ5GPc*0(Js?6gcP0b2MZ6TAC)0#0 zB<}ov73y~{S!HVz(jf|GU*|2?#S$DJD#)n!SCZq)ueKRvlic{@FKb&`-OVHCxiC(R z0#O3d*5R_nHk~#&G;VW17U`3;QgDGd2uh_INaN6 z=dSxPc1rEz$n8PmeSx%DNj0 zkI?wj%lb*wR`AUDUjh^tmkk8yLy7%c=tBZ-*{Z@~_x$1! z6*T%GGG}S>a&~EnN>?9p7Zl@hEE~n~a-eYXN4j*<*SUCF_|x^ne~W0QoXt^;Q?ne9 z@=(K0dO$#OLVf~Hx*g}c<356W^1{6Jx~kmcrLz3G=q!hE15|`QP+Q1S2r-h}`x!gW zTm+2h2Hq$}i8rdIc%ni?Nzg8)3m z-&#WDY*^J7%oAayR^xn$2BipZS3mS zPij5oDdjgO=Z5`Jh7g!gC>qQwE!76V1zlI{=V_>66n18Rkrf{k6Vnw`HU_iq=X?1W z#W)|7Y(GZ)U?+Q{v`do$iJCrfmn8&+*FUyqKKU47Z7Ji=iI4~LIo_^P<|9qE-_O;V zjoybponNlaS~FMTre7aKC+vT6%Dpb_^#ge#ay!a*=<|7@C}j03F<}kEzR8aqA@xM6 zwBvJT*+c!f2mvdFNEab-u&Oi1rP4bU{tQM8(kw_`)r#Kr+(>M$!=%;or(3g2rF3GUADnhqiKP3iL?;R2f>lXBEgA z3p|roQld3EIVrLRBO@>a5%`z!LH)a&vIsaCrkDs{IWj56+)NMKV| z8S)RZ#P;NpI|fm)ph-dSnG!ISvTz)CxZdAAk2`9)9y5M2he1_xviUegyFpH=#?d|z zOt5E;>Z}%_$6Bl}qVRg)|mx`3L0F+3t^BO zu_AyiwriD*@ra|tn(wK#UYFzM*2b4`adK7!N7nPeq?6ha{LqkmDl#sDBKr zi#fIxRzv#@Y5_!!T@!|(7bPj#CoQ6_U$CBk{ncFMDU9OZs$KXx0DS!9 zGdp{k+cOeMAg)2b*|}9jt^zcI#q2C$ysyCB+SF6#vo)RF;%;+a;JhtTmwH{3&^E79 zjX_}A-9{coLBtB!5NGZ(5UyMB@Mvx%Ne0s4-dv-sra^Qv#F_54M=s0?(ba(X%OxbV zUNOY@K05jmu(9a&AU-O%{j&3_2i)HeIOeD-FNmI9vK!_@S(wig@lkIo87TN3i%AUL z9Zegtvk1CKafy}Hd!U;1AvEkb|ZGIP-91N0T=(gg~ z*T_RO0_b0i-FQMSmsvVr5@Uf>8)y*T`Si^?@t;+d?CV1vD^9+xtm-Bq{q|Dm8TSD z8Bc)5-V4q5EsH?rEx{?V`zt&M@6RYmHAMO(NFc)=8~H4jM!F6&8{r;-JZ%NA8}6wz zCzpiC+z{W}E9Y6d|I@WCm#0K}gB}k&Ix?jjlC!ht=g`fKs`u~zXnpqnA$?wnKw9(C z%*==8$%^rAW2BOn{=+#2N&faxa{LwhZu25TUsei-V*{WEBjp+_f|(qkjfe{SY_Z=k z5s5~+Axo>Xyl7bjm)Lr2Ja85B+(~0yGppJQ%9ZYvm+Q^2?^ffCmEJF zY$#T4Oeby?16x7j=zltP$wnc9RZB6XGGyujk#e>xdG#7>x5Mu`H?<-ul?Ef zYC$hhrPJ#V_6~NYLe>STE+q3?WAv9UQWeB4XuK-sGFj~R>EDfvhiBsmYOd30MfhMb zVZ+hT3yR^~;=H<*+-DG&UoFut3e$|U55*5!g+CWc9SLaS zz{82~Gc5UyVNQpz%7KoJ3d)$_^T~>Y#nIiBJhxLlyro0QxUB}?D1fLX_Pb^sWG9fF zC98QrQVrbl2~LAiF)-5K3ZR%EzMhu%eafjEQIK?B*+By+AlUpM@+WqNbvGIKfY`nE z`n2zdS#%MHfy;|a@`s=EgA7l3DdW;hMdC~)x_J6UNhLt^xgb){NOv}}H|DoZb0kV2 z*dtBV)z2Z_^P7tFc%h^7@&F&$Gw9!stNf%7^eAPap%I(n7YJ936HME!YFM1y-^pdu ztzY~dBahT?h?2-%`IFAO2QzPzc0KDkI?4jwxJ?)Fd#vdic$03>4FjL;Esr)A{Gq~v zrlV;nS*2tMqf9WVflLA*7%9OXX7ONR3L5=|12)j*UmHq?Iah#IS8!TzlVe(c$K4J) zvK}gfnE@CZyYvclz}6VCzq`;2;}4@|?z20oN0BxJsJ-<3m#V_x@VTWoMe7hSAFGEl zp-j#HeCIr{EDM-h_vQU)OFbn3Vt=TD2z9L4`Y5F9_7$$ zf&o}=O)D4?L*{56J!%rttf?Ha1LdI^gqMRjsL?bRE?kF^ULcm{>_+P9VbChMNB_4m zP%3_~O#k%xmj@|c(z&dUek}>2o`>g)xZ;H6kO2X->Adpa84`q*`SjXBttj^#!P1``v4v~JFyPi|>}NBPa6@@E4~0z?GU zJRhO4F38w3`TO^E7!F!qUJigEx&Hr;v-gh2`hDMkZFuCJq|6?fvqiS8kxU3?^x%$zIXgA=zI4-e+}oBW~mu6vOYc7zNV0~T`*VKrOiyFj3oUPIZu{3ugheqLL<64vt4_jtqo(~3Js=em zANld6l*xDLAjMd_TPkYh_E}|`kwS5{6wY-b$1vU)%O{X$gRuE~Yire>A0$I^FwxAw z&#J7fELk2O^`FE9?#C3s0Z?bRwzkgC&aPw!0kH?k_~#52+iEaCWi>UU4VadOqT7oHrJJNA~{qEqB`S>i}m$QfEl5rL7$g z|C;d}kgM9W&oE0Aug{#!^csDHm2;xLAGSADbfj_0HhLmjaey}rglr(R&L}GC zfgu6!al>#3TZ_g3Cq&+4DKN@*028}(ZigiU`7PLdV6 zYP{P>uUfuoum!$x+|J(Uid-nXz;j^{DIA0Z|BDf1j2D5Hvj_ zP>g|J$DH^YpBNi|Jyya3Gb&VI22&VREC6PAP1O6VXlq+6sR4lg=Z`JBM&W(fdZ=X> z9a{ONUIqp#YG}j&(pzNQaUmii0*L5a;6zLTy>oCV%F4^}N#bD(b4SZhd)s>^rjDiSH2W^LT1|TmUJ2bIN?f?b(3VcONJ0B7U$1jS zw@Oi$hRAsNm6`e9zm@g%^-DQuF)PpB{t_1f)(Og=^1a3zh6)y=XO^x9MdKrlZ0+ox zmJERi-(0<&9pKl2fdRNwB`vMtqPRvx83Xwg0m4pDFqUau2ill8E(>Jz+OXZ=#=}wu z2Q?xhBmLcG;aa`vUt*ScH^#2|?Z2tsBOz%N2~HW}N(rs9e`K}aU`xQiyg+DO;Q0&3 z8MquKu^cP|JDqZ@Xj*4?20n!bQsszOGd>mcPH}cgRj*x3l(XjO; z^a^FqJ-mg+LmQyfm-@5d1iW;`sNj3Q=?RB+u!e_d#!HLtU%?j=6Um6Vm(b6|@g$22 zjrFI4x(Q(m?{{*vtNJU|piF=YlT9x1p)svevKJx@X~cE{Y(I74(ANRKCQ`YV@Va`& z&CRW+&j~l?mYg%=SRj7y-W7!9o6t5qS)WUY$*a$o6ob&I15`fNo{e zdY#JNUY#*Tt(IPP^?P$KlrAcXWxr`^q4xRkufotG5FjyNmokh(ECB-hfLUIz_u5XP zUEf9O3@Fi1*#ju(JRsZA`XiH}AK$PPRLyfm0xZ-~-t$VuW8;(KvIq-6EJ8>w2h=l? z%D~53B-HaeLMG1EW&H^Ej~4{08_7nY3`8G|Si;s@3#n(_3PA>f6nqWPKDic$md^T3CBS=SI7E3upgIeFk?wN11y$lzJh#qQ(uQauyRP zfI#-M0P-)u)=lP~tGgEb$wNxNYdeJInnk2+KmwsOxhvgUGUS7Vd8F**JIFP;w{_UM zdMJ1&X=FJQfI1LP5RWWNC%66{I+%SLl}iXMqs2Q>#&;Jl3SE?fEAAchoVm!Z$*l>u z#J=3{z{J9Whp9bIKne1u^I}c7KM7ekSy?3^5JQ(4bc#rpRv^*z@Pk9vC7E^I6E1`? z{w|mg@w;X?v#0CR+lW(FXLwXQi7?pqTe)j#3AvCqfa{#7HpiTaw2P!`v2M;_8?xSi zD6-Se5xoPJWD2M^Dkgbma-%*30*3+65)>eSjbQ;z0BA8Xr(M)UHMuoT0|`?*K#~WI z?NShd7RB79!GaAR6aXDwwKAz|f@(knL%x>}gDMCCdwf@W7ZUr?YGjR9V~gZxP#w78p|GczT=nw}w`$qMOc(iRnW4LC*By!%lJ`g^Vv>z90oIqF$f;nvIBp z9jg^@9CPMOB3tCc-_^x<_duF6d=MM!+6KC+GA%xQ9LBopx~@qW%LtEK7HtwzKmc8F zZ6`-VP%UiTpEp{iH`&3MdX*k)kMehAEriuK-1T*~JGzAT;^&(iS+J zmfEhBT9H>RO`sn@wGC4K*hut@0U6dpKr5UshWk-k&-ViF9P$e)j_6RXE~VCmaKCyf zsTk$g1{=(5Y!96{;yj?Y2O%_2i?Im^s6&4n-*4d$(5%DrVVew=P>T!!1f{+Z=jv=n zfm8&;1~QUJuLY5O+`Uv42CST2!sX0d3>Xu#9UUYnJjb2Unx0O<3<$=9$H_6qX5-^ zRF_PM?{d*w(9H;J049n<#XqXlS)v!dZDAXD84b!35Nb@6XoCg7ge4OIl(0f;Y*A9~ zIli@+Z3M*XJ^tIiblR~1ur7N?M>ei-%40;b44K~o4My<_e52_zHF&bKVjj2G3?mS% zLX@s7`{(Fj<iZ2AcNoN6&q81v2xXpWNp`vLYzTq9?k+qvE=!gQWvr z^&lakx)kr}Q6)z@6;qJ*LcTItx}&TWHL0d=e4n~<4gDoRIxL|A6p9r8CuB)T2g_Ibk8wAXKz{sBIN;@U(mb%eqDRXbY zckoh;NAPzn(h470T@ACB>pH~fx*x=%-pd@Gay7V5)rR*p{#rERl;`d3HQ5>fTY&KK zT~?tdUx^0T1E}2U7nV8kvS+_^@ZOpYw?KpY^EcTaIZsR}H8?pI6K4FRFing!ng^?$)3J#d8x zDz3`M|0u=QAOduEQ!UG)l+F~190B^hPe-Abn!T{#f?srQZFoIKIhG%mf(V$k@)5>n z*B9vSml%|5<*Wla-<0o)i7~9>_Se1&6A~q-wec<#UivDvi{blyR*(z~cbn<_8F3;M zKfTzP@%UkksA$ctH8Lehi{y@_PI@DaB2&|}X&S%k(6ens4Nc-~=P%8d51HoS=RPp0 z5LZr<7(Q7@wp-&C5xTNYLuJ?~vJL$`5g(!!EOYAx-tTWgF_y8=$=BD~&D8q+2>#-M zWV%1jzZk837kB^CIDAIs+?Y}wP@yCL0Xc*+WCcjM2B=@eyAzwsQW8(q5D8V4)z@gS zx*Pou{miOJSKF{mhiGkvfK9;j(0_ajT=g`2t{n=gUMqX-W1Sh$nlCI8nz|w5fJ}r9 zyv;wH6G5PzE9Dk&IQ}V3JJD$d36)`s(qlR$N9j(~u(5$8SHM@p0W$b8Ukn%uqSOBV z%T~`a1kedDwcq_JSEMExcakd2Q3Qho2M2r}H?TX9vi@y}0BY(*H;zdo37C=f-(oc> zJ8IV+^sE+4Cd~iQIAgGjpiFcMfX~3yX5Hi4twcL$DRRjAq?T&uN{K@3+3Qty`GmB1 zCsoT&)4$R$U%jH*))uRKo>N3fG4|@EIq~qjXRGR|A6mtk^q@^EQY>ZL(2>&`_-wM@dhT5oA_h9tE_73hT-*4Bt^KbF9Bd-<*KZRQ{8vL}O+|c!6 z?)y+spaj^!1mj`M84#XKcOhaK!N6#pfxRk%jM*-p*XqCRf!Mnc)20SQ4PwVNm(TXV zR&s+@nO4FzyGYhMGt@2+3NnmvLZfdt=qs)DnJupo!P`bKJx^?jPIk1{1T4RcwS+(CGmsI}4?X z%3Zry^!~RoVaA6;{yRl~pWQOCbO`HOmZhWV7(!t3X_#9T-1Su%{L3DY5>D}b78d5v z-Jf~mqOr~qkwq_wp?Ue<_?y!hd4Jn`qx*^bQ&Q*<(0f%Y(+2LTC{-P_Y!JW4CirK& zmwW!Kb#}7Tt+H=6o)!-LBofEQ4~yJ2opZdxeBMx&+E#*C>%H01S?h&;{F;Ga-+cFw z0y|m1&U-UP1*V`Y=RBGUK98o?b#K0J3Z7!9I=Ez`c7(#!uhC(@vT3>foopwoj;J&- z!=+=&%dZ6L{nHI;z&`0;u zvnQ@538B?US7@fjQCztZG2{6jb;ZzHj{MF?9D31;<@#1*5~x_@yRMKcl#~HbFZuBl z3U<~z(793)Jo}ZDP~z(=_@FOUEtGG*effHamHg0p(TGu@!=$;#TC1I@9mTAgm+A)t zataR%JgtY_i0r`5VchO0*K@F zB=hCq>J$}nqs(bb4!b4pEhsk!%*mAJ*c1n=krI5)AeLuc5wTI@$_7ozqF8n$)ECe zTC7=Bf+uVfY8b2#bC&~?_KbKARW>46de_79f_sc7G&9=yVrFzUGoiDnv#-ScA&9TO zL3RG02t4c^dz?qtHpr?l=XOcQtu-^3>2$7MVQ-@|wis}c?kYH0G>3J6%JN88lZ#8U zeYxuWK7Xg)5nC`r*C~PoLpL z_Quj9wd_=MBF)Z?Z{$SKAeTk<@?pfA3eHFp4g>-o`SU7#~BYsk5r=C=W?u?%4; za!yk+VS=tCg|HtHGndR@*;!0n9mA??YUII)=i1+$x_bVSv{Hdzjjv|hJ3YTU%Y6M8 zzdd}4UnM3EOPRsz{P=dse_-0Va!ju;#Gt;f()eM_*utpUMAgn`x@VuaS3F;gboxuq z(9Fu<$#e0mG*?*{U}3?n6v{5EyX0#_^ic7)yG0wwnPthy$d&x~L0GlDZf56+BpmGZ zw7{rf-z6#6Cpymtdc{QeHQm}vuAUcMQFdx{z(z+~Fs{|?WytolQq(-4Etw2St>%(K5=ydQA(TFc}?{CyOmbdXn@Gv zqW!Nc+x0N>Ge-EzeoSv0k}KZGr>Y(K{ON=NLNh$=%I~!i!Dew?2f%xsFSjqMoug35 z0qfwbxDMNki|t%3XnraA@dj5KmOIVnZHfq!ti$Hl%8A!m;2*p^odUtirZ~K;~4+ME*Ng-q;BkMJ~Vf! zUbanVF)>w5N~O$Pt*<#uw|Ko3za8YU^~-)O-$q+Kkn>%>HV=;yTG!?GnzS!FuwA@> z*xG)aVvx9(WE0X#9gaNVdSGPeER*O>87{l9x5rQN{46)uRF%UC`OWhVw++7_Oe{TT zF@cfTP-?ymJU@Hb+D(rfn{V(DE2pf?Vt@z%Z_JW>Aqx7NO$9uxVhzK^laG@3_DW`y z`vf@qzOuE-c2B>Qx}DB(OK>sOD^i60i4Cd143tp8522|jy+s&q#i zZtL5eJ(|?AO^;|`_*puB$l59DOVoYBPV!nrc$$PMHa+gizKQE|xe8~IzPJ-KrLBpg#kT_U_i}?%DGhMhV%emnBm9!~ zPkhiZC>|D4frC0|m!^Wg=Ac8<3+#*`!x}n9K{-vr(KiSa#?M;`yqx87^SB**n2-Mrl)(^GfSS3Nl`~rx{zSc{%aX9`!qVvP}ZL zcH)u}$X!p3a-N#!K0-7)-eEtHcq=&T-X~H9d$**bSxs&n664~T{{ymo(--bS2BhA8njA}6nrT~kr% z5+yD4bgGu?wBFpPKj<*Mu51A&G`15GnD##7z09**ky z$aSNX91;r10K-_oM90%U>Q7hs#aJ;xpyX%qIJ3wgF*S|~q&~;Q4kp47k)5`HfonU7 zd!u$3#H979o>W)QWnvZWIl3-Rzp=RDIkSrPN5v>rvKxZ;6@K&}8D@KE`^xgskkZYQ zxiwUn(%?czdjw`OLgPrvkNuF!j>R!)05YKFR#8@7S$0i;feuiQyKXB{`XY`F(zSYI zo)o*|$vIsJpX_}M}n4>=ss3F9qNt|i(ZV`SX zD)D;ah`nBfmkyhz(P-zaxuGKFVgAh1B}x5k)j@O)*<;?O!4XBdos?R-$8Ot;0*8;E z&r40mSRpsOXD*e$XI*x;0y4varwtk!aae(^E6h4~@7Q;jVh2ijjcSHIjku8)0gkQW zk}smi6f_A#dC9s-#yk$jPc~=EsJx4PLfbAD6OsJ9o4==U-FVrTH|RbHOiJs7w-Z8L+)E~ zvUGkb%hh5)U1XZ@s%z3=lPtRsylX2c zdT(ohNY~C@sP!esWZAN3a*-E^hSC(S6e~iQ zUfIaMzOk_rjDNJPZ?^C@#d|T-z6M#va5NCj_1z8|CLJ}(^SZq67IK#p4G&v^A9S^C z3gq;)f)~|_|ALl<{jXQ5b_cBh+*br0Hb0d-Xkod-H#L`b|DF zEC{yl|FIH6VgQ}p-Wi9=>7;+^Y|eJ8LVWI+y9TZ`XM2N8j4KxpO>%jKGeeYrwV+6= zE-gn(v8gkd|Cdkz^(*RW_=`?YB5e<3(9s6xrO>c}uoUREj`r@eEHtXL1eFXL4gkWH zcY8Y``yW6_;kl`xtE}7|FO(w&tLdG+X*zl~16!aFT510XyvtRo?Y%vX z5&Tcjc+)uwr#|!=`f1?axLE$muEso8ZO(!oZ`g1aa4RJ52BdZltu4U!nII8V242T& zCq5E(>DrDM2;R}nq8Sfp_6R~y&=`mI9;Q4dU;_T?vJr*>SR#V)`~DFa9t*zvuUQZ$ zC)K%7BQ)Dr0$>KJ@Gt72O+j54=nx?)2EHzFXOIl(*F^W{{BcvPqL<(vpclXZe2}47 z(8BQUS2_eAdpZvR&Tb0h*&8Pj$i|ZSc=Q;2)OUsZ;A0_E-aCA#K{GH}Gx%nW})o z26eNcHRs;RN7F5!p=IplR#=J{gTT|df0}2wjq0>{B$ULIu(FLW=|O^dZ69S?mPT%D z*(~24Hf%t@eJTNMf~KW(!h-VNFy47WwtX8L3${ z1_fIuB^R0y-WggT6oZh6W#C@NE$vk{BI)6)*xUg2)Pp5Up#4eSsBpz)GB7y zHmNX#QM7FI!5jcfqeO(30;KXzIv4nm*qN{2q*3?l91OQ8sH%)RCPinn{p}Xm=tDS_ zM{T)a%1b5)(FqOH{=epzVQ$xo*C&8$Wo!5N1_Y6?vOu@0ir{LxCeeB|cKfqu;Pz~J z1R`;C+^Qb73lok=J;X&fhoy9e-2q8B8z8f~n`?5@_AvHi4yo4=4cMRP-v2Rf$jf1a4+)haaO7MIhf_9%ikv` z_Y(+?uqV;qf5cL4Zf!9032BGfe{d{iGi%K{3eyc3=S=SnBPEr=OiSEjQQo>-Bm}?r zu0ug>-}w5aLpvk{QMBoE33tb8mHA8 z%tBz!)?6GVVy19Dly!7tgi7??xa7-Xnw{2|QlP4U8%B*9s6$x;x-|s|zkt`=4QZ8C z*br(;c^*gAR8N}tTsxg(48-PEeP$pny(k63(3piL)Mnf+x+m#rmO~q4!qn8c53MeC zXNB+DeAw^5rpd1fT6JjqX{{);HP zlP+fHT6@>SFl3W}Gu+h6qoy&-`EI&6`!IU|Ie3t){S{pAOMF__ac!VMJY$Cno_Jo)RryjLd zCy9!y!Q4J@rie5i{&ZzKo0SPtm=p+J3A^)7>^q!4@ zyL_E6KyN*3J%?)^->&u4s_ta@rlGz8L>CKruG0q4i)uFdZKb_x^ipu9w)%GjgK=#^ z<7R_z>wb)qGJyyfxPX5nli;9tz_zhSfl}c3%9-V(wWa<2?+Nf|nOOZ9nmzY$mpIPW zbkyOcD;D&fciHod8R*#eqfjq491>>vh`-n0CXR6;spuTYhI+^$wSaF}8>J|gr7+>; z)^9*ABcN3SQkmSUy^yD-cjo2?%}~z^7mE;y0mwPFZkRz|7eKJAbMxH(VE}_>Ahk8@2}HbUx+fp>KDrnN`Lm#2 zv%RJ<4+W&Z#g#Rd6vyUE=m2G7Aesi=_b=bN%34FQxqZLKQ{p}zfZCXe0o1?e6tR<&7(;J7^AZe47+CG1S%)baM7do30tbrl(2zD9 z_nhnE*FhT~U7{whO96=73m1dZ57f?%TKRr%ajCqG6!}S5U)%>a5SLEBtfc5!oVt%* z3o$@&eUJ7cYol3j&$mJ%swuvO9w^XxZa z=YgIxaDW=U?qtMJl{|xoQ}K))7j2BO`B06LO^l_&e?#nRc>+RTRp}F{ga1uRA|125 zcnGEcn8p`gx6z;f{rvwM(|+pTb1TNPPW#Ku47%2h1k+zSFDMOIp-&XE>ui>^0z$B*D0`&tyrN>2H*qh}!}-$?M2G$@%G{?7ue7zTsnqC z%%FE2mq3g^Vq;%~sPgQM7uY6v4uOo|BIQ;6!G{gh?Mkw)w@WLIo@)`tT9(ac_jvp! z7cd)rGc@|xMTCtqMN82IULi{gH*yB_UA4h~A2o&8M7t4@VQ2Hl}1O&UfL1F zNO}hX1750*CbwtK&LzeS4R4$-s@yKJb|U}(zL_dCTT+h0W7M5dO&R+Ur%UQof|1qx zw$^+3>9_jnwYHKyIqdelwG&xs>i_$VZ#G1Rt|=*;7QuN@Q&FE?vLuQc|0A}wZ?xPt znS@;v^RCf*Jwa!Azl{12vGS?Pdsh!G8}z7-cn&^I&4H8ZS%6p9MUB&v28@pP`=TYwa>+OzzrKLp}Z)hTNIS3Fc`x_v+iqWDLqKf)UQ=|a%kW1_C<1j#mz(r3yr&#%vO^JJ8|Dm&+#5tN$`3&^0)|3g7tg}w6d<}cQp z&xsh|qp1{Wm?JgoS{oGSZw3Sm2FJc)e!g3ET;%)#nbY#0S;u}ieG>EVp_XBicy!;p zdy2}zl;@sw$AS*F`eCHF_((w6@adawPWmxJ-xrL|)KbxY-l7lq?Py&m@=Qi8S4R8j-~UbizgMX}xaM#KzJTJ%O*`RJ0>_%#bl#PpfRg{r33_^VsdJbqQ&Wcim6XPEhfDzcYUz;J#q`r}?}&`}mdZ<*t@- zQ?oRu;ge&5eQKNQCEEiYw_VQeZPr)iCor1$DOPVASO}~8a|yIoOp{u1T|1lOb&QJY z4JV%!b)jp}`OkZKMej;vJkr~AZ(CSsFOXcgF#k;0ts+TWC4|j^=i{$;4ynm{NBk>3UX12C2Hb`sJ%v z?>am4f6U1tXQZK`I`1C$`t>=7fz8z>rqxDz@bP=weKn6^?lrQ5ZtQ<+OGQiNb%~bP zNqW$}WlW4$W_}1Q>dpI0w8vyZi5^CFWU5@vy%QhXInkGsnfbZgVIj|Ah_}jvh=^#j z)yne)OlzhipM*=D{BIl3yDwbeSMa3RV4jxJmPl81H6*dLP#^rz_YvN2*Dm4@6X4Gb zCnh9>@R;?UyK?17wrWn;Hf&T!ZcFNQ?|-%kf2^yhs?Pgbk)6JFOIG$6%=yl<91(;8 z5*Q2&%Uqod72`E?PkneK^9Mcy{s$Q+;r~X4xelct~W~b@Pm6fx)ZCR+1hXa?)vrX&Z`YH z_h$HtEr%AMM+-(ZV$gL63k&;rz{rbQw`Hi2*X=R2DN^A#oLBTRnOLb5{r|UNKDE^e zIT{s<;&o5|J09KUFn6}D2}Y{lENzVS_a}iHdjH_Eo_@#XP!+U1Me$nD9VH{fZ2-;o zGnjAmmaXkg9ha$y(|x}Z<#%3r|3gYryj$spH}kxo>B4*- zA9253*&OEM)vGFfW0MKZwWL<#s}sk5g7tW0WG~(M+XD$*}EPR~s3&+g(*{2ek9ipg?Ma4SQxLrio3d@+# z1OOjtYg>ZIRr`-T%IfMS)*k#o8S*6c9raCWU4W+*pAaoBC^)&ewz07kASofV%An^(?m&hJ*8Tax9y@m+2H`~S{@1_#W8jw-f+W+UhM%=GmT zFs09M04Okj&tRF|4D=%GTa5V)!6akxF4^f{zet(r=T*B#C^67uAKV)o8+&!IuM;bH zAqo4Eh(VF>Wb7qR5s^|iKdW0F=|cyJ9MJb#>|lHK-&)}-buU~93&}1xcwoo1cysr8 z(uox9c8k@mzbA)FF6ssmoDSG~aee*sI$i_^;l0htx^9?pAF;XYwSU$A+gSnILokVn zM{qQNirL3u3U-VWrjqE7ZLQ%)^*S80aZ4kb{f@Ig&ZD>H<>h%l%!Cj7kf%UDJtrR> z{q`+Okgk%F!HqoT3-fK@s2sSm*jCN5E%!<)J@q(+dxR{sA>bc+l@pPis zg$dhqvhfgBt*{QxT^6!oKmr zvyVn>VouP_`u{4F4G-Z_R=PXzUHI4KHQ&BbM$45|Yd+V+WIqZY*|ZvEN7F{RQ>P;I z%*9D%Q$^DAjfT4z6iS20H4XBX7N3TRgzwfpP8rGA-rMru{tsi$@$#O|&(9|%CH01B-N)02ykET{Q_azMnztjjFqfWqx=p>nklfbRcIC%i z%84rc=*c_6&}=3QrwQ|Ap)@r;eR6+qdH;sZPx0Luv3;swx19|O%+EFf3)bPcSCsNR z39gEIaNz~Kf5aqCCAIJ8+m}H|45k zv_IUs_(ZI#@oOe{IO~N&x5{x5k!lxQ@A!-g*sKQfM@K!j#iVz1|BQFgJ&-|37}YFG z5lBq1G$72l9jB%ZIp@WJ!9DAnUD5l>E+o1l-CBCZU7pVNLI2OH2pPTHJd=%5IyrXH zJzI{`?ZKuU$oS-<;q+rj&of}oU+co{?JU=S`qkd15h?=$2K*i@LU72JvSXgGbd`o`ru^oSqzz zG&&k@J1AcQ>$p-k#AMJ+!m%)d?>%dChUZ#o&+YH2*?qn<;mIwMp87VS3(v2rsEj{( zeALhH@HtW(k=E;)$m5L;EF&ux_RcqwF4RvfOodd|J>GTydEs#=%Xm>w;<)beb-sS0 zfheJdRM?H@TU?hgdMN!^G=Lo7jDRf*Jc}k6BH)uJPcRv}YwW_pTHLtgUI=H$LU7$m z3KP#r7;BeCDr^Yb4)B_D7cLy|^z_`U+20X+q^bET#A_--4Oy!G&c@=`uU~7eJlE)3 z;sj2jILBk3o)rhXbxAhLdw&mK)1PlZ0>kxhxNVqYY9ZF9ISErKIyGR>AqmLvhiymM z6NIC6!HPm9KGTCN>~LITs|Ud}^Vju1G?QcM?i@IN`h>UnQ$|(n=yO;#pOxlEn@^10 z>_T|N>35yoN&}agr7h9&U*^dDvbcLeS*GnVWZD5OSlvd-q_Fe6WeM2)LSG)Tbxsi_C}HIO@KBICxJF5IK^rDIzGIO7C%0!!2rPfxIYDt$ z^CMyHVTX+<@#WksAv*N zF#%%XK|8~7;<|Q&KlqrHjzC)3F>_al{NvJUYSvLL=_(>MRuaVrnLA}gYW<`R+ItS= zy{~VHDQeSPPfxCVXkjg_vdG2v^r#wSvs!U+aW~+RK_8g0S0FpPSKz8+`T-J3uS7`X^=7J}U2duWr1NFl zGZ^$Y{g-k|p)ycqfu-CK6h6axQVC;Rao8fyY@c(&-HMV=e_xa1+KGhFB?pF6#dQw9 z%|GTjNX8j|Z}&1E4gA$D%YWHiKR568?>C1gTrIDMlqzZYanWE8U@S+oD6L6|anz>W zA*u0$UA-D17Mx=8my?R#q*I(4_TEPGZPHv&3 zO>S3!6#d?xy9P&J5B$j8 zbR&FqL&sz!W2Lk&K)spHIrVG;)q~P1)JgK0@jE{`P3{~rw`fp09-)>66G!~W&F!@; ztRvrK{$AnRehMYpO`{9E=E0vOH61732%Mzq?>pHRvLPqWu{lJ0(t3WF+I-tIQK#f> z=ElDLY0Pm>mgATk;7{Q#9ShiUdz>IzM+gnq)cJ|4u3cN{!TbBXPb+IOiIS#=U^R^frqv@3#Wegx;=z{;o_W zAME12Twa6rTam>FIaq$=swXxn&E%5a&MFR>JQA6&Z+0vsLHf?R52^V6w$qdP-ZLHL z&(-~|6groeYxJs}<1*XrL7Y~mRsH+h2SGVX!B|1#Xgrp}Bw&B; z=*ur=V4ljvrpf6Jg%IlUkz;QoKuYfmta~X$GqYccH(Na)ipYi*z1ffuD1d zX^&eQ7dI`Jsjlr6HGfwIrw51AyTqR!PBKxqLKSJZf=niT?M3Y}B21K+!Smzm>~=Kc7li-B2Do4fOG*1)K$}!BmZT;Ip*d2aii@4l&n6UmmWvGLpS<#kzo%A0DWkTT{c_A|3PIZu{YXVc|voudgHTH6!6{AHh&4fl?_(5alq+)A_N zbPmE+5D(NfRcU#|e3TL@_6;X#d9rQg)|+wkfz6ar)uL^?-lGi)Zf6QOaWbpvN2xv( zV=0&zGwUOSGq094=}=U1(dI>R`9Ov;N4$aivdk3Q^K(ssMQf>$C=N_t>f{CS5sCD6 z-Di-w5qUDY62(<%dq~PZRZ&Tvghv3CxGfGAeJr$39}550lJBf@>U8_7JHuEQD0#UT%@nZO=cgo*j-Bn<)66(jguTa{+!_bEJfr*t|T7wsbizPoRVcPl8w zz$GWKil4(r9>Cx}>q4d)h8@23=spH(boE~QR~`Kq=1-$Pm)j>cye08t2DdQ#vdh@i zH`fD8kPv5AdE-f!^=0I;Xyw4TXU~9kUDFcT=!?nCCh-rd8#_wx+p6#d+52JzL4MZ6 z`aSFaFCQDVHy z^34F|3rW166E6X5R>z0Ata8wb@W>yZo_`X|DkT!GJz;;%?{uOr(*=7SW`7ECSn(%U z*T!9R33SGG=@N9mkFVj^Fd-Pg!Yu8B3!6=%F__RDt-&12#ch3!OGCA31P28{n18xXdd`3Rl@3#aI-;rrxO_XYtUzFNuhhpnB>oI zcz<=@#PPEmNeYFxsrC0b#SCaP6Ww|=)c(V!{JNHWQ%vv>5$3~f&z0YcMhDU$Yguh_tNVbI4yxBaQde032rZcQTi6pWCGT%f?Rm1=4OWi-Dnm zDEq|05T3j3o!O!EmD{DCd%nSN#0%yahynf(-F;q})M?mFyx2By`hDbD0?kGHTc$6@ z4@#c!aiA@7z&YA=X3GqXWG%kzaxCUOW*itG>rirzUcf9Z+TDG$xbK0ZK2xP`CCztr zl3;3L>!&pMde1MyFJ>0}tS}Ew?E7|Lu)mWwBW0fyx79UK&!x*@D>hdQAK}UQG~iKe zigu_@e=M;TqyIK{^_J=6H^I1XCZlvV@$BwhY$^xYc+Oc(J7p<&nT=MS1jiin9$QU@ zix2Ci?3RMy^7*#@t3(B@FXPWVh%j%M?~pTQlT*NQ=nW*%O7tlUL0*ReU*uHLQ?;9~ zez>9^KvMvSdCp)5u9~mRR$hkU%%30@&m-87vSi~Q63>;H012?|+bxKBT>K4VY84dv zknFU)WMrPd2F81@U2$kN?diOYocbqg>Ew8Y#`154@irPmk3A1R5Q|UW*iy+H;(Oxw zp#Nq0N>GnsKyj?)zJk#`qPW;s$+Kp^+-=Ke_i6?mNHOAOwNo^w`CLR6%PlTu*(Phh z)1bP0!1v0PcCAvjk2(DBYbd21zkGnS2StmpHv7oImBkJh#X9b&0cnEQkLD-SQ9?W? zz>A~QNPA}z(mtLu4^L7R`Lv9l<-I+rwl>H_}|LdGhVcVeM-jDMHM+p~B8ss!4_V`P8uC!WWkv~m6g z^ZK3m?x5H^B(ICf(wUJD`I}yyDlb&pFsYX?xD-O~2eltcwO)*`GBtxtZ1vE^#&eD*$7TRX}}T=Rv=4qf>Me5_#rpEVpJU+^q<_D2X-Cnd)Ef5v`Kv zRtn|1#CDSb%7~SYciPTR+Qbh*=qiEKd?Bl<#P-NbW{T(*28b0@ zZY&dh?M418MKg;Nm&}=tTU!xI{2ajClU9n-H6!7LkhQ__IikBpIp^ajF5dd|dI|M& zZUZ31YArVClWxf>#rO-#k+(cKeIs(H&al?GL&MyO>{CaxO66%|m+$%$2GLa&Kh)LU zINss#uF!nV?wS)&+UJ32q{8;6-^mUy@P@0ukIWA3xN;rPD~*427ggYBKP6LlO^3mV z7DN6t>3>D}M*3Y>41|K&B-v&F4v8u`=P4vJsqhbYe)JoXM>#3v1bC`_gZ5j`4 zx#{D+v#zcMzDk-rd#ZS|x%riEdslzJ@lrv70BiwVi_N-n*k$huua@I+f=CO1Sl2&I1KpV;RNO2!l+K~&-Tpx!9v{^X>m+Y8ZlW8K4W-VJ=*6B z8Rqar$RO0F` zd}&~v7g@urUZ?U{45nwi;Pau}%Sgh9{CFC7&*Dw(8*83WaOs9SmS{ zG4u0!_(VZ)Z(63Z6u}vg8xl?!Y;2G%#riM}d;7M_4mR=Dh-N*{_?~F%F>GRI;kYTGTUi&6Eg*-ghxyuR{m}o zyy8BjwdKWybn^O{n#mh`_K7tG$s$X=-2tZD8^d%JG$Vtmx%-D97d77;vTNOo`C6ro z0h^HXY@?H>&vZ*1v;)Uo?^Xt7kf$i);l#3yh2|e)l7JF*x9Cxab<9P48R7{XUS;kcyt=hz-sDdST=_O4&!k&rs@)UH9uLvj`w+|%;{SH$YBMnIqD$mgiRs{ zerQSFi`q*`HP->i(kj~a)~zT^la5>;!TxEly0P(U0GB{H(GpLWN%N5BSW2ONqDp!QaGa3ZFg#3oHP8pcj3)nJ1}avPTlCUAv3^pAfpWao1N zjPb&Ohe0MelR9nw*vgu5uN@Gh_v+`E*yDutE){q?j9H&DDEMr5?GzTGW(t?0eBGRZRDWY^rN;fE_A|hQ<($ZZ@h?E>SbR*r}-)!)A zzkAPr%89-A6Em}Bt@UV()DBwGmVAqTIi~k><#w)8$vdGro7BwAF0_B0KqGD(8Kn8S z=P6iY>(=I!L778wseZa<_2|0pkbtJm9Y{7c#uX_NIsZCyLg%iLFT1CwTemwwXwY;r z{Pf{ynQt#)6az48 z;@jj&+q!5m=9awfd$vObw6vd>c?t##w2%1UZ#6AD(8m+Yd~ zidX@(^@<51|6@LhTsoAhV}-ZzH-zHkM6&sSBLKa1XXnjBhEJa#<6+*sZ2Q4qsi4u# z5AlL0=|0a>ZgE`$FDRHTkH0WTmy^Hsk9@e>+$C=LD$qPvKk&s2udbE=DosWhZtB(a z)`fwr{qEy4{$?v);wZJxHg~@;=QA#P8*0v1jX7ezpQm{1>LwC!FyuxqACUgo*@5RB zv4!u1mtrl1MF@qmhhN_Mr$r&yyL2}UY5OZHfj;0B{H7*gncN(57~n)FY+zV#!4OB7 zKL2Ea&k2VINe+41$=e|s~lsG9fnb$S`6+Ixo>aW^c{bHbfbn0{jEg`E4GNe^_N9j zuDt!PU-19li-f6hXb9#%UDnm=tp*t{E$JAnh$8pErb~P$c9(!3iz(T45C)0xq!>D* z>5+P$YMD#;i?-jqj~SR_laFJxiE93IZf9o^_S>ZX7_=$NeysXZNTaxFsUbE5-|a&X zf?7emcxL?QT+_f-q@hOKb%8qbrb}M5djEE@px|`jtd~E#lhV4-qvrQ|Di7MP@^zFwl-vkfo4WJ`o;30@OWq47=rn-@ zg^TbjL*&{xbK@iL_RI3;pScLj^Co|_I}{jmh^fhDTfg%=Yf)<7>`dbqXEA+l{NA&H z-mL-UN*`R+3#6lk1kUk&JM)x9Q+S8zSA5R``O(5v%#7<^{r&9w*#1dcZ?KHVGTBf-hE;nv&U8u4_q?>i7A4f<}FCk*^K*ZCmqqhH-hb z*IPJ$p5G6$Z5rZex-9Q=9vz*4uzCthXS?DOa8fa(1F0Tbled@8uUp%2wo! z`#a1q0>?kQTf!w1sOtrgjvq8V(oJ!OZ#rhNwo3dJo){x3rLgl#RDN?vz-IB^+_`7! zKktirwS2mw%Y3n#+-OPwJ-TYAt4e6DMi7^T(Zcj@gqF@X>xS?PGW8(yRP(xi4-QxW@1%%mJz=v`P}kG$iX#*?4<{d1W&T}yNG`JNuILa5KO%AttDV}(?I9>$WlxlzpA)^ zmPOTpA5l(}e;G0K++jLj<>n^y!X@_g!%Z!o4TP$o=B;MOF zbg=xC*Ol#NQEWVIDAWz7ZND%Jib)qw$@u~p{Z5PS+oZ#+!VJc$VSfJKnVzqAV>FIaZ_J`l@r;`klXTcuRZHsn-OeuHNC~JjA(${M zuS9*TLO!nyv#v%h_O$xyka_#xTFuzmlig#fHly2mons~q&-#VN-x|a{ z&5xh;M5O&81*P9+)*bk9N0(1%6-+wi$ODAJ%ksaCfM@6s`90P=& zSdF1eC|+Uoqv;o27y`Uul%(+(B@}xk+zuNgPdUpMO^A^BHX-m61)6 zDwo5t0StY$(*f(tp{APEIASKdD^8Yf17KbWRg=Xg&oQ@v0YSewYJtPqlYmce~dwrn~+ z$G{gmG#lC*#5h(kij2{oAN~8gpNbutjY0If@<~O_h-#$j$YJN0&dVz)37t%$EKmp+ z{iC5_9Gd$o;o`_+5z!3#hDvWQNgQ=EQOe*9FN@|^wPPikW%vA$udkjoMvY2qTlzB} z2sgn^4sk0@OY6~@i~9k^$$#JKY}<#G$ucIk)xsu%&@xt+%9k18y!O+pO~cJsDyf5= z0=eJ|+f^Y@>Uc9c^Z$OSb-XAU6>pWhFhu`($wg{_Z|Wz@VDaV1KNXjQC-6fwtd zAE4`_e|@V@=`s_L`pTdf!fUcxTKxE~+^@>LtrWCw2{FVh`us;)ewD1_doI!aMMuD* z<14f9E~fk(Y`%L4jnd-t7nw(am-j|F&^>)|5+v6=)1b@ZOCXP zpW<__$E>DR#o(ao+_3zY!XZqR_F~;f|19b4QA{!f);;Z#%)v3O#GT(OO-iE~>p_ZP zyG=?w1gnPoD_MEd^_zeF)7bq31106;0#EtCZy%*+Sh0;UuB|QO4ah!v^|4!3Rn-f< zA@@{$%dq6h`Q=yh?foXa8xw$=J;OXN2kR0_ z1dl{7`Xs=vJ4K_gCUpCRNl;1vaP=;6PdrX3f}vei zCO;xxyl{idL?Qc;0QG_R*Kq8WK|!99{vyR`YK#L@Vm2pmE)3eDKa>$5`+$etuLp}| z7V2fXn)f?#EGPwDb6Fj*^!BSiJalj*fZ>X!dwQn)UT4$Liks4O3f?|%@|%Avg&1Y& zyu#XxBZkLbo$yh9!U5VNufmDq|2x80&MMoxQ%VZc;(X4-anOoGMxc9 z#j@DY-9xY}$1P!|MZ5F}ZDC!6^}ChgJnd*5D+f+&Waa39`)ngA5bg9@u zFggTTID)Wn&`ZBte~PZLe^B!4y&e;W*;OVmq)}~ zW1^iV!l2ok`)EU6Iz%vZcLl{@5FUE$HXkLef zm_)k>92EfvY!rR_us;RW8GQVznC^%#%{NAcT+YE#ll{sG!B_Id&5nsb;mk1_H=$Xt zQ>&!^8kpQW=OZOk@x}AwA8y^K@+cl@DT3Mee1Fr-;3l76sOg@A4&XNN!u{X%P-tVLpb}0~RlDoLPF@&xEpY;(*84u8cSX;(sj*PG zmTS*@0R;fjiYzIq`yNSfjPB`giQIpf61@NmYB;u1`H{-I^#^J`i^iK;p=U}OJOvha zV6X4p?D2vU6idoQ5au2_=A;((bv)x1r(eOdkYR0G;_z^eExEgQJ#W%p@2eC&g?wl1(ixMm)Bro-XW@Ex5s*3hU1yQ3ER&p!)_M280zql* z1!zZ+RbbULt=wDL+?c>Ke_R14B}CjZ*M=H1j_>1@gTrar=W`Icwv= z2wqc8;b-A|UmZVuA+&287q*OFhM+>Z{O;nt|LITaRPSEkenz*jefyfy6V2!Wg~EgxAk zt$|zT+9n5`Vzf~u{jyq*_e-CtTz3><{~-sWqLbHu_Vae4nFUkmzbp^ryO==JF{h8SiM23wH@)m=Go{ORCz*g^{<`fev*kpx-Y7<*(Xt5_ zL{;r=~cQfQ5Cyv@xuj4O5pV3jOw*^ye5_mYdFeH*I~!btAN#|B1y!{BcM;dt z{ClV<;7KWXosuz;fr-fcuEOCwSRF^~>y48)k3ey0SpWajN8f;jOh8ZdPPB69>&YWX zQ~jr6L4JTaw8cr+_|bniZtN7P*sB7r2tB=I&Lq{0wsHn!{h_5+bYzitBCnU+I2J={ zW5e4BE_ad*&RyQQc^Zt33*lgkm#0glxbK^?a!Gl5}O!?Ix z7rzMGbC%1WPK+{&BOMxLVt(|+oqb-OvBLbIu?d@yTMd2E&e7uLJ0&B8*nbJlhxs^m zW8kouw65EB3lzgH+ggIRZ;u^rkND-}+=QR_%9$~s#1>ZKdMJea(fRmr`)Roy(53Hu ztI+M8j5G|Fk{nAxtW?$N8v^79x2a4_mI>#A?ioLmvrsn2a_M8evK=;t+ zwMs0p28t~j>xsnzb91>sfd@}|ZuS4@t;&4q-3~}8DJgeAYeZ3H3z7`3gZ+`?24K{` zkB#NDy$xS7ROLbq@`VzYeY^a2c|}DCF5c+kjx_u$oHlz@$m+KFrvXc327i( zEUM0*)UvZ5&YU>|r{tVD05{VP*-_)F2ZAlXLA=0gw=K!JC~_ZOa!=sgpv{o%q^v-PyL&k zWHy$DsB0=a2v9H&uuxAy1p*Q`A8Xs3JiSIDnE2xn2C<|?aQHW(mxJ&7B51{Ex6sQ_ z#;+t+IQj9~!_o0^&+D-c;x;j614Rt5q&|H30G#}ej1S0a=b1RpLUsb^uzdXZk%WY# zCjGkbc`-3D)Lm_DI?Ol|W9ZKAu5S0Y$0+yfan5AQ%E~+Ncy5^w3VQqa)Q*hM&(6+* z^y<518^9r0s5s}f^8m)6P$1=#eEIUfo*bPV3@I%+dGdAu7V=g>t3=DiH|u{H*w`-Y zZ!ceI6LVO(3P<81>I_(1Sg6FLBvNv6AAw;9?13V)dN>)Ll%76EjY(PYQ3j`<^kYNI zxV%IKi#;kk_Vy841hr_3rP+I@hfFK4!;)@bp}w3a*w$I%$(R0 zZSo@f|9~cA>bZ1o5h@of)aAC}nW_iK{_v4a#GN(Q3>x}Dj3te#UD^)Qa-bQbH5%+rO(^D z`6j{sj+bCX(~iz`Chb@*s&^JKob23Ka1hrQJ}3Un$u;K2zG4gd;D*NHhp6OELs$)X zu#Gpp{LWvkBhheQdVDHehkv)KfBh&*MFqs)!_F5Yo;~ZWFuFK6G$dSsgG_`h*TcQ@ zF-($@4Th;!ra>t}W*XLni^poWg@yBz!ppuw_xl@nd(xdLE;iK=`?+9#R>SfcD!X=8 z0FsAB4WDjP*DBm*oa@;c3QEJ?-QBBeoF4WpfXNY7;ox^@y^c?yrd0_?bA8IEVUJ!Hp_~?fB(KR zxZXa|aX%?kIlG?qK-zj;vZ$Dmn5U9B+ZfW=y| zGaleD5hOqYZ#Ia1gLCcLH6#k6p8tGDUY7@Zmm?opVKGd%Zx*TdY>#k1#SF9%72By; zZKfH1Q-D^wC-h=-&(mNdg7hg!xhY_Uz1`j0d(kvnTk_?THypmUv$r=6@$N_Gj>Exv zQb?}dqWVkySGw&>Lv>ps<@OW1({cCppIokjKv8*jHm{LRLfPt;-QwW+2svMR*BvZe zBAUDKclSMo3OL1EE{dNJ5Z@JO1!x@L2oPNe^E+U)wtJc>0KEU^Op;9ZC zt$dV#-px2sn3si*roXpq8lEbyh+5+Cb?G#=PRTQlqK0Z_| z3CTBTe=*$_!`waj++t)B9eI2r8E zQ`GzS@1N%?NDO3UWg(K)-*I9iGq!=S0WFLCSG{eY)Mxt5O6$@9ok*0YMvuqCZ@Zt3!v< zyKImSJI-@BAGQiOYUyVs3rkIPBuH5CiqgT1HT*uHuwBt{&kJdkM2otp3oidjerWmH z=Er9ajjN%}T&@QWNOl>ma9Rg8Nj8Y*WjoR`W+APBqc;JD^55HJ!RY}2b;p6M!~1vj z=MC2cBiCL$(vR+RyBy=JC2VF8J;+0mUHv9h%LFVR-gL01&u;5o; zM&2?#k8k^!eC^ve??q8jQDiay`AGJZ4-a^dGEcv(1lJZ=UtI7PORe!c9GoN_!lBzCHcM?A^olP%faUq+GQ>6h|;O?#*k0!ES-~Xh>~k z585Qr9^Cogz3(2Ncs66%eXIo3e54WpwmjurIFXf<-j%dDtcuqKQY!l$1 zdPBZk7XlK6yO8ZUVD{a!9VUDZSPKzp#lFC`_2&y9}vLTEz!{pd2h-b~Khyy$9H~wS% z2)R`vryO&rZo|fG?|$`hf^631lt8qdci5-T$aN|N2RrPBsd+CpT(0j)UaTtr@(G<> z{`B z6V3k#_!%C+h)}ZHG>p<#1$O#x`-Xa#TBqpGAEDuGosNGsv4>LF?gnafWFC{0RziWS z@U3Pytt;8^k6(kDCz*HE!P-Mq4Fo78JwuJIma+oy!4znDe9(D1n9!cfBWU>_7W{?DmQoUp1Kk!S*EO3cUkk`2N^Uw31A#_r)OTRT z)#?h;Pk2|V+FB^IqoeRglBMKZ5WW+Jt;$Ts&4vroAKK%=2=m!2^xguCg11G4RJ?fC z6=S`#7At2%A(C)Gm6P6bjawE(ld;&>fi!nmJq3KzU(D@qCW$lTsrEst{Q~H_D|-%g zuV_TVyg?kQ8wFHdAlTLb3IBcb(&5rPzJ;5qr5vsg2VDdYDYyn)q9sO^R$m$g%}oO# zF== zA6XHa^vvdP38JM(B*BBzfB!QjhAymg1qgf@CfBzt>pdg3CmgrTqDw8wfqBow$As&q z$**(C^^H@Bt~Aj{i{hipY}(*k8#G8m7%j#8_C#SF}xfGt~MDF+G{px^p!#x2(Z!4~0GB3i8I>F!4P zW%bUBjNTKfpsj5hdDv^e^Wz){1!`r@`wS0TY1P!i%GB{jkO526GY9_+VBrV6a@)fL z7JB1cm||U{#2tLe^lxX1*On+~svkYZ0xy~sXF&Ru)3f;neOVhR%gpKR4SLy4sYDdO z#bV9|yb!J;+WM>8c89#LK%DJpJ7b*5EU{_Q_+MhMYHGUChc_ci%Y{Bft zI+L}vK1(sycRQf`YZW=;oNoW}u{0nHZ+F+HC+zXBj3aye_|Jemht(IFa{B&~XZc-J}ccu~z;J#VgsM(CMJ$|S`+E4%Pc7KN&`O)39f z_Usr(@5lR^%NS$BL0@j}Fx1sd9s~anKVeW_c^f#*9gri|9I#^V?9a$$YVZE%iS3eS zd!I&hSHF6A*UiCN1SG&eK!(q7KOzJeOULKu4@Mq(?t5-W+L{Bp!m>AeG2Oz$-;n~g1T+(rBlJ-MW_4m>dw58#h!n31 z91j3={_*9Oo_b+i+I29N!$dW{MUhbz@t6grEd=r|rOFaT*V$TjCPvl`51(4~9x zyoGJioQowU#}${rXaMF$FhaBj6-+|D90q1Gm%%T=l2SpibU6zS2{4HnZ3-eB2d4_) znCpy|zx9pvw6OFLiwe*WAu<<;ZK&s%qdlU6$8OGN?j31=;dw;^>z-NK50?pi4PXU( z7aoFB8dVIN%0P1pZDZ?CQrInhz@lTUbT*Qdj|$mH_OPyQ$%qGtx=Et%oExb&S<3^z zx(yB6;989BXqYlR%!X(@Luc)Y}Rdo90i+8o|7u<&*`J}dK-2)NK16r3>%3_AaOF@-4|Tu*oveQdODO=(S@}hRmf@vOO;lkC9nrTxtHx_=eALy90sS) zpi08*aly(1>_+Ma>g6{U8l=4`qF;1Tn0>)m(!`4vbm6#`X$2Fe((qlDAyXdsCL=~@ zn{DKVaC`kQ)+4oY4aL&s+}5QW6*_}He|Ur;+5N*Dg8B3};m*|cUcz_Wx;Bi7JrjzQ zCn+^q9sth^effD#+vb%nQ1trxytU}KXlszIK!kEnO8po~K7-nve}4{lct1H8{K#?( z3#E7Bj}o~uPR%OlDEac_8~Og@Z6XGhkxcs|Dy{1Z;dU1khUSfoB$^SA#OJL~{hmn# z(WbgEqgIyg>rmcFiBCJ*=3|FK<%pUyYjsh;X5DFZaCpa^sRpcY>cp3Pd{Y!|%SxI( z3@aJ6hfG%HmtY&PSvrf_?L}NxAu?vGykO zUBjv0GQ}-MK*vezxGae-fht3ujtRIYkWZug9N8`PhCR zUCIt5G3q;im+9RC?0a0^LXyq^8y@)hy#BO@g#m*PR0^(NYXbGl{yz13U~}!j1$%%# z*YY>w$FTW0JtGg%AGMYk{n-SqLxONF!_-Y5)Ld%fe~hyc+=pNHmwRNimYmKdnV5id{S3-g&_>;62>F_j16@8yeNi=_k=~r!2YU&x`Ml(z zCO*eX2zuBkyG1ePvSGLgNGWV(?!v^X{}oj&gN})4F-xEjO>Le4NE!x9QsnIMGVp-l zMGz_KBDA$MgdZf09U<2+dp*6y0Wx{&p{?%fCq>P+FkE?#Q=tx^@dUH4JpBo~PFIMt zv2L#w0(#E2Jo$@Na)r^h$$Gz4vbRrN4?|;QpQ#8Tzg`-gPJbs}OW4*#5-1 zhzR^Ukf_u2a&cX_8dNqOpvSb@*tkX`;&L9m#Kcnv5VM(VVMB1-aZ!i=spE|TNqar0 zRsq*QRQNrWm2%6Dh|C>QE=1yrC_48E2-hx(KV_Vw`u6%GLd=@uPKyB%M}koKL&Q+> zEC9dAt0_J{{;DA_70O*Y>7J6(dobyYn1r&Ws96y54+<(OluyhmsugzZV@Lbje|W;0 z%HB{L3A_foUKAJ{Q4tIy+q7m3O=N}Lt5tg$FXR2M)lK;l%+?Yl{Kf%F zM^Fnu_Q1Wg>A!wfG7a{o5&U~A#%qERzblTOy4{pi^fwIcFPMlBlGYgFK|y4S$od&G{^xSczFPpD)H5HdSLAd0aq|m?H`QAZfIf`}N)QI9M3g{-UEF5@EF{Hw z++-rSj(b3Pv;6gdac<)ueZ|%q*7MQPYBB7lrmGYNBS$4t(>|cC3TgR!Vm*?m@W9sY z3iOWX`ieOYaU9xOf)(hZ+1 zd_22al;HO97es5Q$&`JuRO5Y+=W$kT;j}YC8}PM|M8C?#KD7pmVrOFw-Pi*Mf^>Q; z`22?lKvqWjPT=iZZ&f#8Nx>JVWkm*PQPRS=D}oGZW`y{c-GFonc@0J9&C+AKqpsOTE`nhfI&jmUSpmK@^+uOb zz+~{@Qn+P91;CF=c#l~(xV416p_9TDlRgOt5|U`+e{0Y&tzceYwa-2;OFJnGHc~OS z9iLjL15@9C3iuW%&mhyehWtR=(gs3{MVxd%&Ycz)yRE~X?0Q!6WumWcG~pGZ;L_y_YUvLb3XoFu*8^XnX|i9O3wq(yq_$4~yLA%S|87Lv zia}+;Y09n~HUa)~yu#iOTT9+Tgi9|zgw&XFPyu~0FbMBM*;$eBE^bh8`(Q=?c>QU! z`XKk^*d@p`WwnTuL%vdo;sV#U-$o83KHAftf$sTqKg4~|iR8xe`tpYaO&|^e0|lgp z_n!U1dc>YnMQ9pvTs1w3pj-)ELGL?^7Y-qRfzR}e}pLb7*o zPpj|gp(^Tm?XYEs04~FqGQju|@y?TNVIu)Wc9QS8fGYEl>zck9q1d4Molu!0OnWq? z1eRbX9I!388{PohATR*NQlhUiYA0OV=63iEJEMc)AeWb$B2qhS?6R|PWFU25AeE5P zqjFF!uE_#lw;t?G+JfzZd~YLS)ZacCiTi)D2-D)E?d%tnx1ma%A=EV}Kd`O35xHGTKE#3KS>go6kzohV$ zQMvCuc=Q|MR}m`nEa_R$H|APyN#AS~odJ>fCX-PiG*f|C3Th2*shL9*&f(jln*qod21)B!I zR#w631a@SU68#*KkbsvWL?DUBPdvaOJ6Cw??9tlV3kE~Px)N$zaQwk<9IglpfwvVo zuY6~zc+mdn1=v-A=$AEGSm@5v9brVXI@0471aE=T1wMfUaU?)9xZiq&`G@y(j$~kt zk62I5R6q|dU2=aYk4h*mn$t!*Mp5BDcz`GHgO43#AimJM15ZlC3=VOX2sz%Wuxtg_ zkR8JoPPqQD#W7zfeh|YND9VRjjc!525U(uT&g!--XWf1eHf2yaA|7{$8x9~yy-ep< z0HAVW569DUE1f41_c|b%T+Xs52C+J`vnWiw2DnI+I}}DpDgZU>tVjk7Xu z;r%P)s@5=1en7eic4a6O%n2-;HuDFGv3CJnuPzQw1Umg%m3F7@eDGsUk#`{ZQVwi& z+y~7u3YB@%*kOTFeE@rx`QD}(lY#>-;i$V=WpWM*uO8seEnnZHfRU!v4(A*iZH2<5 z^ljSunElD#o@oA6Rx2oDw$D*Z$oGFH3B!A4byViCPa}pCh$n#;!(jGdBfzN@h8ouO z#gOHail$Z95?xCC40+)M)KxIZ0V&Tkk9X;4M6K@4% zNQMMi_1Lp$p>WTc^Q0Yy#;cGJC+-z$y+Bm>pdV)#Ok@kuc%?Bl^OpG;2gGe%1;}Dr`i!%^E+m#%T2CDulmUKFYOOHd))`o%)&f zTQI4(lE>RP1!-x$S+gb>1|r*pJfiBM(49^b%2LM2kvITp1 zoI92`DQUqp;70JCQd8wG^3ffNR!3Fwx#45nqzS26 zoP%LSQV86U#AA+9)sO&lKJQiAIAyTxL!SJ4^DG3`xk>2c*8Tmd1?DwDA5}*ZL5CV6 zdaI_BqJLHR@)@h|06zLL7Q(~-OwdCRA=uE7wm7<}`=J&ud2>Jaqj zURn$d5K|IYR>8y%vG4>t-R`f!B8+-)E&sfc^!nmN#UTpU;ci~ZGWZOE8$27KYHi$W z-}Z0AD0HT=UGx(zONazB7})zDmy(1D1%%D720e_svjgtuTHYwVe*IN~+UgOWyaWV&sTi(e2 z%WkoOLtfywHQy(Tr0IXY?-^06{`pQ2Ji9p7Gb3UsR(#}2m$(>PsipBqL*ks&HJnCF zPeZUkVs;YH+7Yo6$YYg3ae8W+R=SC;ngJ3YVvz0DGFt}zr)ot;T8MMXgi7T6t;B_c z!kv&O9z{(s41$vw?4isRZ?n2Tt`MTY&&OXoJna`!n!&E#m+APHDiRhlqwm@A>5Esu zs|E`Ii{3IOg{Rn@+jbtE)?U~ySa)Z*d0c~J0DZY|+O8Bc&aIs&9Fx3>y%nj@NAmT7E0Qz+{;LV2Rw5ww{^!tkKD>CJ21Lpj+<#+fL=fTnKf^IaMu43v^;JgCj z?BF_C4}#samVXiuwA3s1bpl_D{&d=?7C;rm8u>wEz$;&$Kg8-_HV- zJLMI=IyB7WcyAD<4wOcQmQ-Sls=E#)daD&ICmv|N<}hy&RG3y#+htIF@WZ7Qlkl#i zxV9Hy5(Nt(9wU`vqNH@_c&B>u3MH*7_2AjWh#Xp_H_>S^Kp!*-S#G zGib#C)~mGTDa4|yhl%h|Ah6ux$as*WNxCl?o25Kg@ekib88!4i7JOUX!Bznb4kydZ zCDCRDi`8u>mX&kOe zBfxY90|caH0oeJC{%wdmt&wfPGVCz(!3IPcAokwN;1diV!9wSBB`@|G_ewnmTM$To zaOx9%VQZI*B=LYAUi4n!z8N@qB{dGPn~p*VM+ee$VzwY?tu)a;VS?|2%nv(F2UNOc z1>0%D3Q!J0;DrtD{u`;sQDSO<+Cz3B)7sRTktO+<2p|Oba>!%r?&)T1o91Y@@bA1a zUZ5-R0N5&rr9{Yjj16^l0%Ig5Keb>;jG#qX-G!8imD|1(;os&`+tAtD(2S4(fs~*~ z?%81w$#l3~=l}un#+%nnFr)73x9c!+ham!}|EpK)G|z%8zCoTZV~~=w9|R-n0ZH^Z zm&*NI!*5OSR3jH+Z-w!DoAjQ?eD0DgD;0bJ_H#;M;EVy8(L{NrnBLd|uJGI~6@kvi zAxgx97`B=(JlDt|Pep8RA^CyF!)(KBgxPU4+r0i1{DeNKeM4d`l3GFcEmVkWnV#ot z9$-iCpQt6@pbynQ zXc=GBmr)=+*86|uybA}(z_sfitQ2JT%&1WeoNxfI;Ll*1PiWGu zGrfwq-(rsR@Xl}414zxl+r>Ra2OBG{68$H~!D&x$M@Fu0Q+14y9N9@4>@Ui?qW@+By>DYVfRLIDQ7T{1?#8e+#%@I zw2KOe%-irV~h$$Gmng!|51bAjqW$8B8u_7qa z8vs*-V*36pxzTGimWUG>j3;7%2N!g4IZM;?{!y;_#zJHnE+|syVy^gr0joEwU18Vo z(rrs|it5`)V!TBw>V+U%HOEjAzKyjtiCqRmSAQsF_;&;S)Ao772gD(NEj7p``Gr-3 z*vxQL`GC19kU;Q)zLAipHQ^7a6Q9=HWck;{1%7oGb z;+=k>`B+$T%ny+hEu?pDTho4QQ%HaG0ysG`GBSpT&qdHoZcl@3EpB=Nu8K}Bm=~G5 zaOxr~K!KBa>>Sdu*f}ULGLR^$hslI&HM!dKlIfe8&(v>fhZ6y2vE}1evk^qXJ`9DS+PuY3NTPDd|`Y1xaQBF#Llt;J%O#m z7POOTV4jeMMztN4(&DX#29r5DfI`6l5-Cb+mrW3C6hW<_xnR5|!g~;1Tu{`&yA>$6 zJ4jaeuR)*!)~5cAALW4?%AgWwRF=;+BX&+DJU#V<@Z&?$fkh%Ng!+hM+y9&}jTnQ% z!=hFi!RqasaXqB&vgj^Vc;=+`N42`0gzt(QA)@+$Lx&K^rcr0=zOilh72TWOjZ@^!Ql>nt0?9ZB)R3K+r1QewnarpzU}B<2l*i{JG0UT z0Nkl*>VZNkHq8N)x}X??U(=uc9qACFPlxX_Cak+?5zKr3T%NCMdlAez*8zMl9aOL= zj~Mjrl7NKa*8hMU#3dG-8EYX^^`{|at0=92fI$0aGo|I}6%D|lZ{aG;WmO|vpQL;j zj-79AMu2~s^*`7f-D-%DS%ZoTC;>@vA%0)AtYA!#r3coISAmGJIdO9C2++#y|G@`k zCAGDO@59yuhHZkOBaXBdQAm&=)x`$KP66m*6%9~H_S;4QSL2^&GOtkUOIT@;-O0$v zhVI=$P(OGTQvSOKt>~;ma>NIkM#zo`Y#k{GDxmmu95Og9hH=2M$uchLB6tq2EVx)K zhJ0UR17BPp3^>5Q*VrP{P917GhkE&vOP?fBj=gJRrA*Cc<0r=7a>jZ|1hI zc*Mh$HTRjn>_AJ zp-A00wdR9V2eIitV7~^M#^aBh7}$+hAgv{sS;lVP*M!Ch2x*a739o)y$Q~0FX4J7( zR^K}1VA{@R5_s#EH3n|+(fIPwnDkN9V-42wj=t=v#Q2^Od>m$k`7iNr1ox{8*_x$i zf~310!eAtIF{^#+p)2X9>Pw8*I5Il<`sI>i+PL9pkwGom zvAzLvNQ?*u{e?WBggu3S?uIs&3f;a6WQVzrCs|C{ozo#E3kVKwh~zV&5@~2@!B^r~j`f>a{~Re*m#1~Psi3(1ByBHo2- zjrzI}6~T?!oZG#NuJIqBvUv*AKKKn53BaN+d9$7ZKJx8xH*F)(tw!~?wV)wOA@=$` zI+;`%8~k>qax8|w>3}EfTjPb26ojEN`T4WT2puvlm5XamzBTRQ7cnqkyI9Vkl+UW|CzIUcWXdV0 zB8LVfVbP?r7 zZ1ndmLhzJ@a(ZRJZn{F`qyS|bb~xR65JU)K8XnQcITTpJCpSMwq>ccQpDu6od0u|P zYbQV2Q}%|P9%=Q{y`%AkGte54j2H~U;4n9Ho7Q5eZad$A7cP>CK$>u|ttzJo^;er~AhWyj%cC4>8mL z(**Qjvym+!2tSa5eKYAQW8&pg1s(bUMU?NyE=4j@Ws@nE_0=By0Pl|9U|x?9hN4B? zlEHl#+G*YFao!<*<+bau`@ru_q~1PfN1>4hnK=Fg#D#OHv$%VV;9H48 zEZ%f-hFMtHZW%c~WK0aU0b*om@I25WD^b}y(;P}ML)~*!kToLW*tt4n;yn2J4adJ7 zl}$dg%+7M9`iPq}*LYmmP8}&310q#Uu-h2w?x|ii2+B-AskA9&#R!-<*s2xX$I-iKQEk98gjqHuha=`v6Pb`3KiQk%x4}7hDky zaiV$Z?(rVjHe7z#f_qeu^JYv#&5k?c$hB)tL+rA$;w_8eJDnz{zjja8po?>&U%drf zpw!gn&G7Nh*Z5Y8_`zuYfGCA>hx};;yh@W@@0@JSX2GyZlyUctKBCE2s@@8f1Brf2 z@8r8;Vhon|W?6Z-!x9(!+XN*5$wB~yXEQAx~Omu4vD)b+kJO!(J7~r z)?-%!MU0!yZsPQdY;+)Cv&-zv=C;;1oQ0_@s?TZnk%NZB6WnE!>)>>tubh6cVwC#} zClLUWm6*T}4h{rl3~?}>DS@spoPI%rkdR>K2Lsd6Yp@Y3eKfASGk0?FS_H`6OY~Y$ z{$MX}42+b}j|+(fglqcA90U}ckMnUKXi+L-R)AVQ(WNN%|8eym@L0Ct-}r4rS=nSp z87Z^uj1ZY2D|z|BQ@4a`a(*t9?9=Dym@z)arLlHB(Kj9tW)!DNV`$i**Ll9pNDbmM&)U3p=H_@SlYk?~1*o4Q zWFe5YuLCuBL~2=8Oak$L$$bB;@6U^h_n9n$gN08jJa6T2nQxJ%JJ`xTpLpIW`c@YZ zJ8fnJV?5KB-skkTdM`T?>jpp9%ZM9Mi?**X88uicy?@^ycLa(M|2bdjW@g5vTSqxU z=H2!@erKElpld-rYF68|MD=TyPgM-Mjw~+xb1Xu^GY>ycdKW0i%l;Uv7k=AEL@+ts zPy1GH!b=U4w4r`d$OH#N5b26L`Dluij3NEy26Po-QY5Vhy#GEQwKjf3qRG-*dNBhtLw=D|?jh6X9X0gl{2u5N(VX8;r-rx+C&g%k z0v{y8B*K3p%$!yR)mySKwh~#Tw|EoxA%se%%bN_qD684B&S?8n^i3o0{&=_uW&UB} zpShLm&_xPur0pMh2tW!QBy91b)zzUSg>kbSWML4OL?nyMkWry-K>`iR2?J#gN55`1 zR%|6xs)=Wk<$)T}uv%rQ?Ft_@-y$?GLtpH1+b6k?}x!ysNJ#}U(2TavPO1oO zTT`4v52qvSgS;M!i0I0|P(A6X6g1ljJLrC1m$>XkJi&1nw&iluj>^a){AwC=vVQ)=eKfV=-a zy+eqKMU2E>Hohq+`E1UcOza94#T#ZSDL*T)~7 ze`zPC;@02OZiXllq3WKrhHyn~u6E^Suc*>IAK?7EcV3raA))oIOUEOa=9<(~_`;c_ z+Gg+`q6R0`8%?_ANq;2|-(QIT{*r3?1w`udu$cydvokg|?}E1rI^hw2jQ(!o!ciqU zM~P4Q$6j^h@xsed38=rOHMi*?vz_ES#)ncvWW{w0L?y*}n(v}sC@|TsAQqTo&6p$q z@Q$)-YOU=MrD14dFWG--hNC`Ucb`G22>A$?^C%=1ag^;va_Dz)NAJJGvfMy&o8g~9 z3X?M=wC?SkBt96=n)ZRK7g$S>?IM0;Qx^3&ERTU@m&*CC(UqV-k6yP@2mPlU77cCt$!18Av-iHmFVca zM@J!4H+X0uc}ru)QxiQ*#Mb>#GP;bqF@PVL`l^z(k%k2mZq?KuiLIJSI$)!fyst}W zo4#@5@)xuyY?O_Ys;1I^*XF3EE5i1|VCcUZbE_>YDDfg7JrHD6BAw9ZZUkJZfG`)- zYXZ}8TRBOvtq3&g#JnABKeeP^ojh&W9kbP@PekeJa62@yNB27a*I;vFNz%HJuf9DjFlB)8YDcj%B^)h6%nPuJ44PTVvC z&!}u`uUsdX1l{ZNXE$Or&T271*6H7R+-O-DOS;i+8C3wEgMg{ff>%En9QgRZh8V-C z+3z=QdLwfzmUJ>$9}0#d{V!ByAW3xP4}z~Iq64+TC$I3VWPDFm^FJwwUe|-#7~koX z#B0!)VJ%t$6b4w;6w3Xt%I^Rh>NY|B*PRPOMlY5P*KF?b{Zlid0sR}PVjlHBlvcLL z<`F%&O)uLj%=e-vitMdeaCV2VD0fQjZDQhuGd}gIRl85U>P~oJ(yxn(?$C&N(a68Q z75P8f4NG63BLShY(lp)r5nqNcv9Imk<p!HfpcVC?gi))$tN*3-f$2A3a5JLX z2;?|>78?xc=j%6n^yrhsqfacp&nsvh@S!o7NVMdinq1?463J+>Z;6g#44+z$1tpym zImc~%zlQWf1ZR8nch_GSYJ2%de9{LLPdnsyz40!s;!C+K=aCH1yr1tLcZk*wo0X5r zw;X_xDY7qrjnaG?)_xZ9ZzCjg$W#9)S8ydl`l-wIPYGgqx=7=BPu>Rq>#!p!`F{Du zFvmScM!gfI`%=BqnHDH@^_k<5?rT$Zi&me%WGT|b;u@Y5D4M|#tNc*3WwV430l?BRXc8qGJEU??Ro2g)PD%@)-dq4xW*6P8(`_) zMDgID{HnY>%W87F{Twr2-KyH{CkNIzv9+zzA3n=Ht7pNs=FnV3dFLsi|7H4cGz_e| zu`#{>(X^=$8RN>;&10Fd_&VU)gA1W~cOUdlYc&z}<2gCTU*uvw#9&GNQOTzkHT zvsb(}H8;O@?HUI5Wx5{$mIKDndFFDm*$l!-U;g=~!1TUwT4Db$U#?5Z$TW0zUM44x zUuTyGV())lA8WC-RKVv_dy-&&bpRci{Dd9mhqN?rQMYpNGcJ_~O+35}#y+$5X1-PE z<|%g1l5octHFb zuTp49zh>hECb9g@O;6PR;$l++f6nYkUDBPaZ0W1Hs}{JSo3~G0pz{az3|s+WeVJaR z)6{c0xkBNY3KD372VGBc(EDhBc1a(x^TW;+Wb74^o;}%L8yl>2=DgF7BHc%!d{DAd z_tdEHB_t&aOk0Rypl@AEi|kT&D5!k>t0Qv(?HB{q`^-Z5(HCFPlz_s05=`mJjMl82 zb9;^~y&Iknq5~k{o)Tk%)c7NjNI({mGFf8i-r7xzr-5Ya=IVB#xYA~|C#-$s+3xZ$ z*&HKE=b0l%ntul73x^^x&quskgBlw7L{}`avx95Uxx);D=izm|0HR8@^S7LiS7=+e zMEO5B)@R^kSR;hZzhj=g4xhlEU0hv_&Q1Z;I^9-zol0}`s~>=5uVV( zw`~Q`bzOBlP|E{CP)Se`0lWtTrP}=s#uEBRkE$+x`-D*4qJs9laPLC$z4SIc3{cKD zDOO&rhL-HjpV3aQLIuwgw_PJOS9&_tl3j@3L;0NJ(D|c~zMdk!oKnKQmdzek5qE;< z0iG`$vc&zTr$%BPlr&A@z72nf@MbG+SW`N0&ntQyOa&Z3cA2EiiX@pPzdmqZ#K(`W z_+bW!LA|bxWtqHHOZ9I1I=Lw>{Xk|Lo>S+dzw_7sL_v2;+JMo{bXia3NNeyTpu$A4-e8~8wv!V1 zooown8cRHZh)eX z$Sv$xrVyU?t&L^h{YQ3s+DF-2o#9~1uv#v*H~3pobFLUqO~0*<)4(>s(c2sIY*QwHQ9BUH}Tp_--dM?d?0MOg={%f z#=mKu-n(dB0_a3%b#MS))~+~A5hJZ`@do-8)CRfjNAo$lxPcC1-1(9OO8%tQ^i)*8 zzoBLH$O1B{K1Hoe&*5FXOsK(Oz`DEo0nAU=5`gn1Yk>{GQ5(7>~9(#GOY`iT2q!nopZDW0J=4ITOtZf-)-p_DHXZZ7C7G)#Vfoyvia2+{`7dktng zP&w5-IjJe_Ed`a~>-FA2NN!f-F=?oM?HKnA)aeVNb z!`9c=ailKDo~l(OK))yh2;*R26TCwNf(((?lYg_Pa7U2Kog$&wIIv~8h7@dx!(h~K zP(;ZJ@z>wIZZP%lZ(?}Y;@U#g$k!AY4oL!h65s=WsYzwlldJABGtpD*l-9b4@r?L3 zIZUev_A%C?oE?wfclUNzUKsUV8olr?ff##Y*!;yrJ>*e?H+n(V3FHS56(2;d3K@T! z|HpLdOH|aOl;bBnk7INZZmU~K$8OJi2uyE6err}dagbY5r_;qQCdN+hqG|rI2;-TL z9R6KWt@ESp!s*=!)MsaEK6SG97%XknV*Y5l+xYaNG~j@cyyWdq=t2b;n#eH{DKbLu z0YXT-qLL!q?xqTNgAkdZ&a(50AB8LnH2Zwvh#Hpt2MQW*ZEl*sy+8G>qJm0%;qoMv zHWWQw_V}z%<3>j=z|_ymjf$#o$BHS^IVm00sT4Jj_xGE9+Rvpp&C|%khebxKANgiI z6k+-F_O|7&wm>-j$V^&Fc16~AM4l=%6z@O1qo|7OQO_hxfB#m#_4pem0|SF1mLpik z_)CZzrepk#Pq})N;L=00E0^&p0xMDBdX$h9 zY$%Y2AS5R@+xY-019cr81ZZe0XHP?cB?N=5r=d|70IO@mF+kHOfp*WS@7zWR3*KDj znBZfwD)jgOy#ZlT20YZ|5+X~2a|+~~=y=hwxcZK-kH^CH%N-;5wM5)2zhy-YOM#{8 zPB>0^8oN*^KUrYPo%R^>ciYgVqzNyqITnMf&J8T}dqoRpfxm6Vmb*Hio<>^Lm5co! z8E5XH|8=O285NKjzt|o1en|)i@?j{Zsu!AECY2aR5kD8V@{&ah>Hco6!2+V0^H;2A z?nSy{ET0kFKPaR@pwY8AGI2)kdAt{0af^o$l&b;=XzNQSyh6IWRYU2;YPwKsNH=v? zij0x1Z3HyonN0r(!keMu24)#D$~Yw69-!UiQZeX(L)ev$GM+b!fa>z<__%(e69bT_ z{P#K%P}xjYDu=owOlwS6)4T?6hTd1z+$SH4_6C4QcK{R1;*%vKfLngE@*U&S+PAg! z&W#+j+qk$cw;qQI+lyW5%mj1L%8@G0t#-uQ@x?Cb2bD}u2qDR~blT+tXuIC{LGtjb zkPsD&l#aIvRsi4reiDg!_;`NdY6VFs?K? zqT@@JzGr1;Z=7$BEpXj7^S4)o;i86zCWZMfHl)y-&GM-xFNx-fm#4VXj}0#%HoNTF z-*!7|*EwXqVC4_vcDdcVDWRD7;XC!m6~9uk_n>(xtFBAN$wzz8S$o^#cHOUf^q1!+ z!e9$;T4j}e>c55|ph%m4qvyiIh}|V(%Ty41$(2`(Kgk{d>Jj540Yv#j|9PvBi2zx$wW~UDAB~~EjKfG&NN3fg9P3t=rBz04j2SfR#x^h z$$kYVfouh^v6M}+*I{FT8c5yV>PV7m#_$U($Y_(4C#M4Fr9q~mp$rRQLjBjb0bDq{ zqZ2ZiU)4BSVcB#MO^@0hnty;_f%Iem1}AKaqX0VlQjSw^H|#kn7U*hoADciu>~~w^ z6=&RaRJym*K6@OC<6~NzPIm0U{mjO9gr)tL5gH16x9m6pRMW%f%{0%=p;!^EtaH!< z!fT+4Fat_DP4M7)O)95eaEC4ntX`?OlA@Ziv6z-P4K?H^l;*y`dPC5Ht^N9GSCyZf zx1@)|M$Ht7hE#!|0)HzlmN@=rVFnE*di6%U4osJ?EEH2Z(BfvSBD=PW7fdu&e;fXW zz~!71(DA7b(h=jA=0v8}BE^>=?)(@ENeR&|D^qH!^8Ebt(e84Y+iqs>Tv7S&H-sn4 z2ImsHW0fcrzourjNp_|VADN-18EfdVb#bq{qjhOW@W$zUJED@NLs|hUOK`K4V-s<_ z_|*RPh?v|MTTf-R8_qx64^a=uXRpoI(yGKqLFXzUC4_rft!xPGq*8q@|Ba4`f>iob z1$SZUgX6x3mSlqikID#Cba3&vUf1VtW0&WAOo;xe7CzG~rzerNAmEkFT2$qpcCk7x z9>fV70I>R*QRJJ}r-u~78JD_{qJ$&Sjan3SxV-ymZl+vLlXvY2F=*r=8b>HY57X;d zc$6qV2?j@3Yn$#bvgzsRb@9T^cv#oM=f|$O4%c|Phjidxy7V1}!5f}7FuarXB+>hj zlEaR5}g;H zrnT1mjEb0Jj*URt@@6xAb7pNe9D<)aq)JM=w$~}h$Sy&1H#DOidhe}?a%A$H(jQ=7 zX}sr3iG4|iC|b7nja_4xHda6J_gl|ND4u7v*RxuO@Qzu;e%;R;L-r=HI;4zQDt=PdM^%*_k9zoHDuNyQyzsG11{Ze`Ua7#F1c zaoO71+8B3V|K8k;%~^B6NP2qgeAplQ`l#p}2VL_H6Poivyz_LY_u1{W@&&r7IQ`N` zD@-q*in#BHgH#^lwQEKNT8QjlUhNqbsNU4f6)1ePeU`?(&4tN(X30R(7S@cQdfP*r$H#ycX<5 ze~aQ_sQ{L2qPPoNbMPrE6p256fK{$kw?F&w>%5vXs)y}yK_KZ+R#`c56mUQJaBSTx0J7Q2FVw~OK~k?aHtawG ze+ELk3Z@nTb6KGN0WlmM*Z-C7p07Z2*9PirHT%g?vTndU4c6E$z`lF<6ghAR41OYZ^kT-&;lF`x5A|OSrhNrBhz&=PgVSA(<{?vXlQM2#qwR2ik_E3q+ea?{G%;x8wgA(OKy$ zj;o^J>diYFu-$j?%=Le#sW!RFB)<_X-7u@eQfkzI#XKfNnW}mzRW;~wq1ks+9$tYx z`tt+q{bEK7Ux}nT&^qLD-?OqB`sNp%#6cxg<{j1KzVinSwGnkVncH=ZMt?u*Ft8fN zslv-5;DCgRNaf}gpeSUCwt1uWic;iCbga`2me)2l5=$vKrQc~oN&aF%jDmG&OKEsc z!7NpjtyJzuAxE~FgK1nOp@fkl*2S*)SQvDkJ1PqCLd8AmNlKyNbwB=m^-d%tzWaG> zeb->;1O-|Ig@*nBM~ujcfmp5K zVs|PGB(REd?^9|*69+-a>FH@gDymk`Kf{5I#`i#3u;%Q@67)?Uuh{*q&iuS^vIu_= z3X=>VnCg(n#<+jw`mE>!Sikm^v$Ij?)B$e3EHB44j+~S7gM` znW9<{;WVUR1**HK&#D<*7QL)^kUL2FqedHKnMr?=nVFdn z9pxTS(GswmM1jzvik{x;)~;rrzTbC4Lia29Z{<=)x1$1w+4HzBm)}w9X8Ao8cSoy_ zjk~BHknZaQ$f>m<$`jHf>NG$<+9MavEO9tfZCJL&WeEvEN(==tN>VT6S4Yc9sKT03 z3vxc*0!=^?_eHe`Fggk!xX=L8XJus-hqVAhEAnUeh_AMk{Yn~Ky{YMl+N9$JO*Rh0 zshiu%k5xG0EakTFQO_M5KtYsqPgR$gnmPpJYkxF1OR_}=)517)lz3!S^3XtQ6N@CD z=;IYo$&dVrJpGFMXP+@kXlq@ordTE;FVfMTNBGoy zlf4;_6f<9i1P9lHvda&Bxz`HJB*7se_G_bpNL?&TGp`AheY?$8AN@tyY?%Hoc%-<& za$e7q{uzArSH(lkR&ESmj`dGRnDAU{pjX4*n*ckD}MkXdk@LKjyg65&cbNSZ2 z2h35I46ZRNeDI+acDf3i5=^oSXkZ=||0N#^-SOg=mwJKAG=AyU_zfr0>Fn6Rz#mWe zVmBcyWW0U5XUzk&56wCg*sm}1Sq-A%l>&eSa7(-B!Bd@sf!Z@N+v;8NcXFv?Wy6=s zqHk&n{i=PjsG9VNNf>16Tcg_MxTwNVK1D@EX6nx(1z`H)D-fKbhkR6_VoX9cgA}wU z;dJ``KL_!*wx1N^JzZTWOVNgaBeWVW3Q{jYEI@CPBZ)8yphnX#=q~eXxsUM4|5)m+ zKe!)~1~#1h8`A{nG)96#5=d2l`SL|Ptp`z-cBVWB(W~hp^TfvSYIihLV`Jk75qGER zZBG3v%8wsEBDEeRB?3rMKvY0cu@T~ZG}L<4Ho@$3L8P%A?z-B2A18+gAM5pCL?9K{ zlU7m?HNU-N?wa2FSWC|kz253XOld^rHV29e()ZT19EQF3v2524i#fk#xe)p`>Y zf+q(qAVz-aw-#8k7r2fN7;AK zljwc0 zxAU4Vs$XPUI)V-#HWS=}7O+#S`7AK8x!4gWa`kiwK)$^XO-bGktI_rq;-f+Fpl!6`yVf#{gLU|NYv z$CM?W3fCQCVLS^-io%#6S_GIEyH7+EWcn1&%HvvRkJaX=I_%={L<8c>JFBY#Yh8}~ z-rkyTEFDA~N_o+=2Da!|qFyE{IMX)1GpQ6)o-AP5T<&N2o` z|G_Y7J}XM6)nUK4Z;6xeQ^Q(JUv)i|va-q^bJbC;V`gHypZ-?r*h!_$~nr`(sR zTd$BG%Wb2_=5$d?$_cTVuvI>1=6$d~O_p8ZO2^2rjKYp8C=iIIncvvo&vQN-20sm6?sK0-WKw4YG4Ogmv9fn!Nm!Hy)xU!dU+(V^&$J) zzs^_z*WQY8fnebw=V{&HK?pM`a|hvDOHbxT@opLaw{uy_A2wKXX*khQF0AOk%~kP^ z6oOywmt6$HUb$$L@o&Z9o;5AEN?uo%Y7s4g1u{|96&a}>^WjNOw z{jPhn(a1kncK$DqK+ zqSpGc-HZW^l9rZz55g%YGpNh|Y-W5yT$QkH*mEYV2F=7+LI?h3{_2M=L z9GOpVMhn|1NJvbaS0QA+8ZFo81s1;p0mh!*skh?Ww#1CY1@{QYs=(rmM0`)9pka}) zwMHkXwTg2}Y!%-nECHS0xki1MNVjWUax2M0k{R~PGz z8<1+qh>N2nBqR=+gm~F@i!W=7UUC#D$rA*faBzX7!f|G|KPvngDrBkccT|Sc2_nr% zwi3{NHKg&Cpu;?(_$+yJJd-_$=6JP_ELGsRtA}-LgKc)d{blFa<&;<%&Vq(yA&X4* zGLKnz-2-C=5}?8uAt7@w!Qn3Y8cv1lO4rz7*|3StzJoPKt(#YP`ufrE-HJ-5hm6nT zy{9EvAJ^bSA7W2t)ll=2o{YnCg@wZg-;WSL24Pq`kaEHFC85>gy$-xJn1g`^Eu%pCNFmGv$Hz zPaY+)8wd0-sB6AURy!t^E9U&ZpYU-b%!8EfR3?yCBgQtYDLkG45 zH=1bd%R^zswDPu>OR~HQBMGjA23Nb5dHmthc>pdcjgW#O05$}o57rjLNg*6Ze&g1y zzxvxtOBT#C!$}pg{E!~2X=-BlY)tI^G%hfNk}~*zq$&zAAsF4yLNC9qYb38!MsNHP zLl<3Y{_Q?11!V&7)q55~d+NTmN*!|>DUYC`e z9@Gszh?a~?Sm)g$KvCOOYor41Z%#(1>tI*2{(XsMQm> zc(Ga9rmcv=9|S2qku9c!Rj#+YySoWVNDz;vm8%&NC^PRM1>qP8<@td+`H!%77!W%1i)Z`vcFVafIVV#vcyhV>xPR$${#xf zu@@H!$+BiXeOGlw(|kx*H+O9!37!y?8DQklKYvX8cwdyq=$sE##{Mj`2WmmD(Zne= zV{(i;90rvOka?B8nGj6Da&yB>gIl^~gwiQQQO32>5J!WLo&;4@r@&oM2T!D;HIYAo z%fkUe&pl~rEGz<=Kqyl%aBwI=1Z~EzQotI(MTC%hySfVE;NX1jS@d;) zyU?n5aRcgG_d~L>vPRDZ+4j9TXxs@xB-S^MCk1zG%G{47ryCV}tZmfvq#aX<=1I!S z)biWc$|1uy`&(vFi81d_BoxNPy!*))wl_F-3^49W%{yhQ^Zwyr;-|W56UUaL%MVdL z3HM&+&?Tm3kFFgowLCHw;{na$jyRDB`YnRWyJzA_`YUgA?km|a*eow=ll9mxC~|}? zFTvF%^(?+Q*s*jmJHkA1Dgje|0gm+5*Wa%?UF?;Bl@SyaOl|^X2@&07U}X*Li)82e z)2D|RH|y@{4RFw9&tJrh2}16Bk1AWjx%R5d4(>~-uYY=TZdzut=YPN}(|00&AChLD zVMv`zGZMBG+zr`lWvSRnS~VI9`5a?0*paAzcTB-VOLP5X{6wAz0+uq_AIJSaH9h&& zE{O37G$}H^+}Shy;C(}4?z^PZrLtuTMTliC)@iE@4BMy;l^%V27ag>h{ik2ZW#jW8 zgEEWVuwBjloGZfhXSS%{@=Hd}Rg1CnwMOeKmbGMht1rJ)Vy~55BlQjpmNGU@9-O+v zDlAM5pn-*5XbxitJL`!5uV(CjsjOB0g(sdsvFX_R74Iu^PIU3pIW5~Zf69x7Eb9cy zOdTfk8DxK6ETa`U!Itc1Asq8O{@&hDD5=&ylsRN2TVk17J3U-(VDmV+{mLN*XY#{e zw_a@j@e3XeGZur>i1ycj^fK*Mn0y%9ES~5PR(n@0BN=l_BBuB$n1r(ytFB@q_V7pE z!EWDqLCx8jae?F)rCD%u5P3+;$>EUV_zg}eW0OERQRI+4k1H@NT>N1KoiS%KQ^UKQ zLhLsYnw+fIo9nvf=JF@L{{D=B51TZHKj~g2f74>>)B;m%_U>i%CFke=PBo=*^sreY zcF7{1y+F zPmHQpZw7s8HRQV3Qu^_>Ig}|J#ar*;C{#ZTqZK^A=Q=lM)=orC`|?IXErt6Hj^)KJ zjJ&U+!Mx`kP0tz*;d|+SLLPeu1ys887sN?XR2p?2wi5h3KFnYN6-DU|NWp(hk*ThqpEQD-unaMSa`-8i3wh|fL3e^M-LQL z6>3xp%PXB5B%>HNCx2Ah7i1^547l&^t#C!#7W-jdu1k)IvLDy#pS2Wm+wm#YY<^lQ ztJwTS=a@NK4w*Fp({s{V1eQkcMw?6>mUbF}$8D`@=kh9)Dsl>*{i(4!Pc4!nS+sba z@tYAk5JAbHTKaiuN&VpXID}R%i;cbbtgdUYLYG#icO1`ahF(C_J=gAJIqUrOciWSh z_t&SO@?%*vmpYK1?{;&zVn1@KB!Fa6%o-=W{nP6ZozfH@e)_YZ%lDif-nkoqg=;b< zlDZLlmnh7hiFrwT5GPFgH9Vu8=;L78CiVfc0fmk*T48c9G71%rSHassPzA-r(!d{W zm%nYcya{_K4L(hcZtxX^4Ie^DUa~LFgI~LQ6!G-X7s5qDxP^)$u)j2rl}tf7+>_ho zC*jV{hTXFS^_!Z;no0yUqfj|y;{CIb=-qU<9abv2G*Zfmu;o5J6uE#3%Wdnkw@xZ6 zql8+)edr0aJ*>`{*;bzjgLJ{pe%T3C=QwkpCQ*a}a*V4RZi4@=BJR)OBVkU8TzlyX~-!>wo()K9DAM!ZppT_$s2* z`Stdw^MohmZ8L?~A6oh0AOolszEPd-%rqC*mM>D z20J?$vA0tJOkwKJ)obJ9p8fV6S6||ZP0jCM;{1}7BBnV=ke>Bdl`<*jl)5RNPru@O zET1^&ZgeBBZg+bj)-i%weZX#R!9>X*3l{Peicd)CwPMW+!>_gMlLhXt+WC~F9R}uE z?Vk=>q;3Hd*quqvDKd4+Ym3rS@dUM*L`2^|i3bM<6Kmi2)NW=qT1K=idW?qxoMCWs z7bX+fs@7-T@Dg$%tbG1ln+YxEt^+et6Q4RjvZabn(T1G>`X-Ra_S>ctap!wB4$vIm zV|Pi>!&wqk-AyWd=`bq4WpZ2yEmTw<2La^;B%P<|>F`Ys3{YH>Egg=F zckr|AU0+tL<8@X-0w6}>nApl673Oizv){Za{D8-m_|xxjz(Z}`v*mrXlFJS~+OwbQ z477Kt08M&eio-mL=*VaaGA3(VtQ)=m^eI?RP*7`s$Q&A|eE@U*UBB6}VR!Ok<7zm) zSorL$>6C|>8mZIjH?O1i$EulYmUCoe2aA*URwiocVU7b-qGbO3jFebzOY}YiW&9Pw z+~Q*YB6whpNFxsnk}@33HwskCqR#H$-rU2os2}2x?A^#L0fk4$P zKknk2M}&FkLAABuWY;MqSu zX_b*uy9@#M=oNB*fGiB}P{9y!&LX=|xDt(U$T9dkW$qiDpPddyN4*wqXjpx#2x**) zdke8tMqglXu!QUV3v}))+^Bx&=Lc9{)4&`5V;-mI(VuGo%FRIaS-^3L6lDmXKnZmx zz`3V`|Tx+-vbJCdQ<(H5!%_1DSdTVNUT{0rBcBVwWFFq#^xcSCl26{<*{Gwo ztx~bAx+W7|HFsXCynrNVu=hSMg`#PC-H;f{rKcHwg!Cn-9M5;v8Uzfs#R%mM%a#vY z1(h#9F*hoA(SFdOuBrb=IpdrtBlUR+jR88G$5Fgm0fCjfdr5PXyK9#XuDkXTjL`Q>i%x zkztPiHVqYCXVQ1@0|n~yYOvEb`j?G4|F*3JN|`2ppk96v3K^9}W-bbq*+JKU?;wb} z*itd9{i$r5hWvsvr&y&!$rmcri>RZz6oWusr^^o?EQK;LCi`l{)iGUUd1+$c5_0VmAK>OzK2q&+0qaOD`f?!XsoDFEmBP((+ zA8SiB`UrUkoQ>5c*dLe3EZ{`pmn2(dKJz|``u)7ysWV5Xl&^wcfAouNz2oZuyn@O# z3w1JuCr^;`uMp4*`2rPc7c(zW!}i)}L!aI*c7oW3~$&XsD*pD<;lkbuSf<(M;f+xJ;fM ztLW>8!PeG-O8Kt+W$tuH8z zhO5pr z1{iQ(na%`bv2JOSXA7N)&Tiu)&1tgxoBR7R!1hELf=6d#ySWL}o#`>>39@5EMYGr) zKoqFRpr9lLtn=X=FPIl3qot*lqP}BYu7rXJ_l?}hKoJuu@#KD`4hDOrRb*)EJ0o=EUq9Ekwqi0J}j z1enc4+m{VL%q zg>_IN_}Y>9QgvLu*#Q!kjlH)_0Q1f&FYaxF1z6<-VOxaa&Bn-EIu;w<*#1~ z9>=Stzb%T50|qQ0wOtbSYx;$Rt`m|E7 zsRPx>Xs8vy3R5&=LdAnRNG+?tcuuaW+Vm1ioYyIPK^2zc;-(6HJ2>!Vh|b0&nYkBl`1fA+(Dy&4;~IbGTL89}5E zhL3PV{l=qvXbIkDu6MzHA)s7-gr1~70WF*E@lsYEqo^ny%#R_`_ulD}Y1F5J>kHt4 z4t+qmrv$EF#{@U@dwG|O60hD8>-D{->d*LM?RS^d14^LSMCV&uTRY}~&;1u%R?;zC z+I2&TgS(;4?6GJz=J|;F+(rHg@(Y3mq{?C0e`|JremJLi?UUR7+UgwwYN!!{M?yWd zTitDcQmM}tUEkIwf8aUB+efE)%nRRzni|C5!bO8cdM3iQJRg69g1OUxf7CF2kOEf3 z*(sD+TJEA_7kLmP#)RA$3EsmFT-;4yFDQ{yT$9Tw8F|z(Hbx7M2@?SSFmMyF950JM zSq||aaS6COOffRjx~F#-Ar%66rY<=8Ch}N3%3W_WKTHS(*k%$2loAnppf>7G_f5be z7<1C1bmhJvH5{>%4GiRC!3$nScs_^+!A?_nwjl(59hvos9FvU`tG6&PGetU#*!>Wu z@diVkN5{6>&vGF5(gF-i3C_%9gUL3{HY+AD#0PjY1(IL+$f>BH5_zdw-*%rN2Dl3V zJfP>7M1_Wh-3MapFSFagOUZ|A2Pg@uzdyWF^!OH_^VmT@4kHrCLoOgQMgi_FfI(&q z1*HxPN&pILJg7tKXRE$)+w7u4ABDVwLZT>bd3ia2=!9?0JNhwE+w<*(mPF4AwDQf` zsZ7nxz+IV`AbSzFpn^y9*|UuNuTUgLb_`oI4wMi}LyBQE7wnJ+va;Xd4wmDE+1V8z z2n^3B@Y^PEes!`-KWZ`lF?Bs+73c9Yir@i*iawGht-)`n%vtNXspw=l68vg)9kp30D6i*!^s) zSL1&SjqH8VlG#}Hg5!U{GYR|)t#78601AJ_B>x^6?E3QMODEZD@k-dKs?T7}2el0c z^s}rL;f*jWmFJ)4$ZMcLLs!bh&EbARASNKj4JJqeBMDHF24p5%8X*O2QON9@!b64i|f!G2V9s=XSRMxSr^U`$7tQuqKQo6Az z<|!Q&lVfEH9}UTuS)6zF8VM z^)SzMbM4!c`ULArhv#sn*~*5O+|Lk_+2EiSWG@BZxj|@zpoMaY1}vi8=Jfr_;M>u1 zb4AI&p57T|>Vs6fq+e`rHXc)!XVfRDG zp;S|E%7ZKr0`7`2N^`>p0!be_{nFhZl@K`r=@v@lJz_+OMoDT}jN#8yv@n5sH z9qb=_)ZA3!t#a=k8d%UUuuXLn6B9{upH{uXGO80JZ<9&D(89U zJg}c&3L!mxfWYkY@;B*4In_>p*#EGpE-46WS08}L8V#AzOhRl|B3Io^?@i-VOv{7< zVwO;R<=QsPOomcV$h+Kgn&8>ymCQjvk(U({)ah7#ygJ0}#$1THM{*1dIBdqkk*Z2% z)#dsUD<8;dAu02-{B=aab*}1*z__ZQ$>KUTDZ&vu&rJ-caOAtb; z_1g2Ap4JQIGnNy14F8}(j+~?<5szEsxbHdimhOlGsSfJ>l@`n+Lh-ADm?#**?JJ!! zRdX-?-A&ohAd<)GzaIl3*l7KB-O(8!n-K9l{Xcr9j$aQ))nMcl%kt zGEMQoxbl((20Lk(J z;dPe1?c`s-t~)Hzdh@Vj41`g10ciZH$KYkG)GMlx3(aQj_W&IEP}BKxVm7F3bqaHI z&kh~+KTta?zEk?wEYG;(3Cz*S3!Dup-eLvx_{gebAS9#*YG83(U|Kg28!p0JM03s^;6zNFzP~!pQo8Z;<+7*kwP+%5jok{>uC=&eH{!6vMJV+rC12q{d?R(#3p@ z5um3@j}-Fp#oUH210Rj7`v4jO$Ax``*F5V%5mQI(ZGIJp)30NuF+ReTM{_zo3{iQ>qj8T1xK())2*@AsL17y7Q(z^qMxJKmYDwo=Alt>fXm!&Ce?Op=D z8`cmZDx`E>wDo6;mXnf}t_K7krd)$h`M_sQRo~D6DZvV{S&2m?!b?$4K-oRrH>>Oz z8ax7;)I}81J*LYU)q>34RnO7@QYyZH)5-@ubP&h`8*4OG?}tEJI*Lx*_{B*IAeX0_ zO62GZO(|#(gn`%V~>JwHisCVr9Kca&}po> zi|QwjbadQb#sGxGd9`#q+c3pxQ7br6e&$HzkfBQblG6fSR)PfZNOWjk4}1vt{6L>! z4^e9}TVi%XA>oYi@eKWW;OMAs!08Yi&`{}=-%DA1I@ARaSP6K(egr)fko{kPu_^gz z#`)_00`loVguW&sGB(w|1!fl^5CFl<<@|JiRUbHw7>+iuhNdH3xj=Q2%p1TZTk<}c zp{K592JBoYo&e(ge63qhqSvZ&DcJaQTP~Ft{2-i(W`JeUv}&E;3IEx3t1JhNugk^S z?7xPzuM-Dt6x+b_hJT34b{}n-d;aHz5Y1f|LWpyiqhte3=DbJDViqzvYrw`sxIT@XKV6Q}V|O8=dd#15&yn{-=nn}|*JA6aRD0Jx zgJq?K&PdC4qWMsKUi(RwV}w-Rgr9I#fA!^kM&Ji0cSInrj&W(r$~_SXojq$OKw3jX z0TNLn4CF`O3I5)Oc*i=892DgL`a->A*uvmYQtQ@XlATzR;YD8ddm}p~iNdg~bFu z2bs(}4dg6;yK1mUV4&|%;X{a=q>*KGhibF5_7tBu=!>j_pviHmRNjEFTJrGx`xa9K zjRG7M>L7qC0=t$fJ~llAZ~#d+cdm}yGfZGDch%AzGrjSY?iTn#i3 z*ti~x0>{*Kd){bwsSj{`acOCP!hPRMBtRwBwpCbMT!b5X33#S_^-d^H>b3h$zr(Z87v#Y0zd#^&?zRYcJMVdRWM%WA9(~4!ohD(aM3X^h-hh7cicv5p84>; ze3y_A4x#lFY)mHyXpv2N<&5;;Ub<8eo42Oh{y422s#3IXe}TcWQ7oQGKiyB-8y?8f z34n5l;9X(O`L`{mN&{ci*!&hmAzlv{jFw{pz8+U;X(>ODP;@`VrVE~s&`E8^$}8XT zW?|ogJLzGr}RW{O|!g?{H9*XRe0?oyU=Oc_ZMux}N z4UcDWjChGMM`d)mhDn>mHd%8~Dl01+I_ElN)&2N^01gs)1J}gF=n=OKK_dKu-2s7k z9J2-l*RXk!rbXa)wj`-DIa-l{1VG6SBgcnU`@?Tnn!VZ4P&9)2q2GdAg;K`eoQGWU zK3k3@*gkOCtq)APhw@2!eVn4nc>|ryb85MddR#vt% z`>3F?e)zScZ`2W%%Wc8ZO0l?^S8a7*P#3>(vQ;})m|x}5p@;wiw|sBR89_sR+w6!M zfk?<65E@<47nDG55~{K}OTCDii38EO(4YK+|6L|{cC?rvQK>*usG zsIngW?NORiBZYy-OIQ|#I02$wZoQle|LNPQgU*xY@8T+b>P=XvY1Zfa@kP&GE9+ZO zYhq+*(x;iU-Q+ZRct(UGJk!y6`tzzUCiZlA(rdyyb@U=4Zh@x)aJ^Ma!8j`G>uWde z!+`*vvM^%Yf#H=lV--{!e0py0l9M@m3nlL6ZdW}}`eRp!{%vNBn|iM~(pskXuMj7d z&-@d{VN+jkujqMLDD9FkCbshr)`7$anEX=yFEF~ocxVUpWyoE!d zkogE_--B?VH6<7)i{oYcT-LNdYP;2wo@^ zX0-7thVWpt=PzK|vm8E!%8s@H>o98JDP}CXc@tIV{%d{sdQIkZk5hBs+%z*gQlYX$ zsvd!Xe`+COLWlh70xmfUMNGWp?ab*7jnt7Hj{1WW5Jmj&0Znd__c5Dv2f`l*(!^lo=8Y z+6vL$r5#eCvJy=SMN4RJsgTNO?}7G`_WJ&3dEWQ^zVH4$zsK{4y6@{cuk$>P|2n*X zJ_g_l&Eo4Z9YYh>Ux6BTf`bL>G-KQ%EE!R5nmZfDnDCngp-Z>nymYXK8j&bA&SgjC&Uit@LwSHlk81iE zqA`9r8raln1+yK#ntYpv_rFb4+muM%)9}frMYFtaK@oDHG)$TuNMMSzxLvnuOr-L_uQ>XR4NH)@V-ZtmxhU9_|N+eyY#d5zgu zrc%9b+-SRdi087^ZEWMqaus|Br5`x{{zhln*}2?w#w}MNuSI7*r^QUJpdmxFfbsy9cgQV zV_sRziD?+x1*!@hIDh_pR6B=elwjhIc)PgH+X{dBVCA|qzSlPPoy~*8n}lcFf2{8B zZ@hlKVVlCXhwh;ls>N(3iqEc6ot+^QE*H{69q$a;uMp-@v_FoYS3I z$XLEvH|qVWj4oELun8`K(ce!EEO`1GGCn=Oj)BdXFIWKF8@~!d7*w>uJ^7Y)_H&(uX8z2u&p)hC6;GxOph&*}uGZq>QLHD(My!n&O+lZ$e zY>~krXI1ykke;Gj?~`z$9A|{Ncsv&e%cPoS)m`2kcGi6ndy1*aNb+CjHWgL9oZBT! zytYoA6KQZBG5*6_wCckuMf@(ipbmuopqYOnF?rx~O$bG5&b! zZsl>;Cy^qvv$q|m7{r1g*Z4A9a?SBK?*M&=H51PT0zC8JBJBJ-Q%K}U%mS^Zrfe5V ztvafXm$6#ru5SHMl9=2TER6YcKPJdY{NZ~R!v_57^KqM2=FxdCkVJ~vGp#o4nAmL% zyuWQXQtE5oo6vX=Y2I;obas90J!e{GQ` zI9T*u*|O;jzx~?d)-Y5yotg9{w1b@l4$ZC2PTUUWjo0Dxxl#W@`5KgCNdLYtjrw@5zt^~GZU(gl)BJYi~1uDa^LTGy;WNhIS)IZbLx6kcW$$^>ClP{ zyE_z;s>4w%@6MN@HePvyT!a|b+WYRb&VK^dpZvx?*1+v=mCH$EA)=gA@cxS7I>y!A zNoU74eijtDwb1hJ`d&X^yoW|ZN9E@3lG*ESp9I$r{b7|pZ+w850BRisIk`KW%a0`~ zY>pJZKHq#qGJb@SO_2G*r|#?$~ZmO0%?PhC?-j`JKEi-f}rj zmJSw~=6@?I#VwQK7H#eje$vvRmoz3XzZ(h;3ZQUNN+V_D?1xA#ZiJ) zZ(~(mfQR;}tFKvovuJDwkO^_O*$JainJeG>*AXqyuE)oEf1O;>DC$JZSKQ#qHaA&x zIWjiu&n{c(;V1Qvw}cK4b$c-@T-#x5>HfES^8t~Ubdz)q)0@%`1@N2@><|!0e9~Pf z+M4#WVTrbwM(dvoyZz@huWl^9rE?->3C{DPh_=_VTO#_ll7iwu#uNk5^Th~Uo_4i;;-&9L~AJe=&fj?6C*>E=JydLakttpT*8wm^P zEkPwip9K@w@7ndIvVR@sx)WPOVWDsK^g*--M2QR_=Dp-R(IFxe<^N`l@tJY%qub{q zIxMk0DJ_pj6|q0_vliK=RxQ|qDUUwMiAb5@U@)qCiXaceY?K>4K3g@kuiedZFjzR0{Ww<~wk?xKl8gDKr#j3tv zw{mGxP~E0-nc$v!2vXHAB0g_qDPLjEnm*#0lV^Hh=6aFKQL(a zj5uIHbJ@gG(!RoymSOM^ksQjCExY?VQ9ZeT=X+qRXFU-Nn+_}wwN65;R(+Z_tQH=2 z1F20L4L|5EVz%44AQFU8^zbSk9)I+h4$yU5PK0{E z6}TzdiDB^6ewzekc;>YuE4kpGE-2<2(ih}kZ zCuQAcx{zHO3UA)N_4*m29-HiZ9DN=pA%Oq5t#i*BI&eZ0RsGuDUzb=L9*uta==1%E zi2$$1o$wgcNENy`=s~>C#F+j4+qq#=JCBmjz5~D5LWQz@V3g+CTG9Tcc38qpqPcsF zLG^E;hnwq80oCR5kC|A_@kH4wO&pZqoa>rBNAKR?(8;068!$2Q+BcNW;(O`A8L1EdHK5<81>ettfBUf3C;m8ut2eW)Yx zGyr%zA~hoZ7R)uI^D?N3f_1tG%*g&ySr!Zo#NCv2M(1z;SVIG7v)0OI_x_uYd9 zhMowp2`+P{#Ql5s?mT>WgA}7!_Y$s??^YS@+ec$7#SP@@y69aAxZqp+e)+CinK!Vp ze^r?tx~R`e8We@<(aNdsSII>@+;$Rr&dm~uP2tDh;WWM5+0d|g?O;Q$)y3L~;K0{O zeS%BR#Jxrk=QVntKAUC;wH_&6k(wV_A~>hY-@y7n05l zqJP6EuC#W>%xWxqtd;eFn)m6!x?KNu>0LdO5LiD#D#9ap>NGH}txwcr37mo6*eXF= z@b2N)mw)gBf1z&&2)<_f8DhG`!@gZFhYO)`FOh2(856UN+Tk`m?oZsJU zNy>U3IG1NLckz#(kK&Kxy%af%G!;}-n8}l%2MnqhCFIlU9wel2)opw6>{-p`Yh<80 zl~~}3bYtIb^T|EnJkQcbW|^a${>Si=Y8x#Om&f3 zMGvpvmmf5U{h~42cr>)^(QUo(74N$oc&>I$sinEKKBy__{klDn{p8(D<{L1IjmJ^M zA6JyvWE}W2WI%Q|`99x<1aZ%q?dYAddEl(>*T!B*BB3aJcr8V9N(`0t@`!*vh)i zyUeE2a2TYmzNHy&e;IY3iV?}Q&@^^st!-}3ZKAh-Tf5z*^ZDo)?TFt?3+=|83@8hQ z1L8$qBzyqEJk(amD3tMX2J*VRf!QmA9HQ%@gZSSaHK3ysxEj51>^_B?hBA4s_Rl%+ zciGy{&jMJnO`JKl^pWz|~hdqsdpanpf;yE_d;F#UM}jN~Go+wk!IKaOC@ zG7ZzU8Kb_P85qZvubv8ytrinYcy3j}#>KTVTetbr_p*n_ICq_2Mwzso5Dr=a)y{G$ zEjukUFfS)MR<2lKBMJFrWLeC+cd9YF^pKzkggwk(NlA%S?%DmIAfvHhz=^=V!rUzG zJa}-vI@k%}UyQs5Un!#*D>3XTvW>;D^Wfpbs;O!7CAc!v>vqyq@4WJt*R$ook{#x* z(S^=8B@LB6*1{lBuzM3rb^-tUsJUgfV=KB(nF(6_k6B{JTrksc67NYh>u03{J&@l+ zXnP{asYFILJHEh%(@ozJB~zfWtQruXU&_@h+<(^$p&G20*gFX?RyemF?_Z{Ra7M{9w*4byg?K!qvWWo&#xIVm|-ORv#* zjIjO|@{Izv27uirwp1(;xUtev{!N<*5e}IVPA7^w=bly<85PB8Ds=glr+M~RmD^>9 z>6!|}k6)1rbr3_Dq*;yAEtLO`@fQ1T&0IIxUAuMxCkQ*SV2pijP)LuRV?QLYqCFo~1som2q zxt*RSK=^<|uBU>7*U+PO<5@LNTW)%Sh!Dx=$LHRTGSsEAb0gdw8E)V_ZoW^ z02yfX*)?6D7$)2a<#CAT%5~=)UsL6rndsZ|ktAJ~O|@&Gl9C0egQpgccWd*(`tX-_ zf-T4S@85CJ!pcN<>*ekpB%&w$PO$=LA7Ec%0fgQs)BY~*BAn&R7#Y{(M90K$GwgYj zTn#k@v|?ZqNTen>tG?FHq=OR?Lzph(_-H)~OK^rbo(W;l>eC9D+W)*XhMOm=laOaEqX z+zALLZIcZxOp?cvGv|FWRg7FIv|4t*mSh^slZS$4u4~<(s=wL9dDPh5OS8vYHn@@iiCc+xVRvmq3=&J*6sT=xZxl%Ysc9`QD4Gk zGmluM?FZOb8Y~Kse<_Y-{DT6pKYL6UJN&8f53aZy1tDgzp~KI6Nm&wnWoC7>as-JG zBVZapCq}EXM>`dI)=jHGH}=Z;7;FNS<6}Z_wwCcCM07vO#HXP!e!4*XiOq4A+NA6R|m``E34zEn^1NjEI^3a=*Tqh zgFfva{PEjDB47RV`TFA9*{cb1x`sT)qq2$6D;rO>H6G0w`=S`(9C5CY6FS0a;j1_m zmw7U?(w?ruLD4BQxXknS}9xPk z7TK_Y2JZBh5VgKMov1@K+(@Bm{hRf6Cs2N;8!Oi>3OwqC_fsxR6OD7ax+e))4$vZ3 z_H#h1kauOuh!YuBs%4K$e`^JMy#3Z?yH!Ad=&zPw--l?44hw#8bTq}p-E2yZSU$0z zV6l@00BY~fYp9;r;$zSv${G(eVe+uNae&%8&ty0WQ8Hrx#9|)H6}= zvA$DSRHmLJ>h!mBX-9JMZQ-q3V^Wh1w6xw{^gG%2qS6>2))PR8_dp=XGQJOK2z~*< zjC;DgIf_I)U1O5l3m|87pjpqN%>-SMu#O2>J=8G`Z!VO)#_gc;MtZ6Nu{ZZh#0+=+ z{lY@)*Eb}3E=3-|GZNy z#S6CAMc#{x`a&e6XKMs%VV2|%t)Ev9sd+c=PBx#mPH#x~wNc-!>x}e1M}p^7jW#6L z-n)+o8?)>&n~^%ySri4<%4;VUDdYu(AOvA;)mlwwXU-xb|FLL`;n&#ARaxU(NoOhJ zeE_&@Pz(0`*(0}hY?_fjd6CN#wv|UN8zUKS%@-0Fcw!C?LseCmS^D-x$qQQx!(H6o zg!)K{FO+$4y4f5a$urNsUHWiZS@xH0*3svSC@yLHxcn<-%Oiikduh%f|a#S!C2g@gnaydN45;beAcOtfvn>x@DdD#m!Wx754=|&{fjWtc0l-AMxc-=CVlpXOXX#HurcA zjEKg>7`seI4sa;v(Bw6z?vd+%drfq_SHGuWc+29!`}q@T&K7hA-izF_ANiofHa`9< zFlcAT2-8r9cYk4;NwB%ab7V9w1@;R*p^_`qpLIo4G(j;G16UQ83*zbINYId_mg`M-}A(ZEI$>!Et_8j~lEv z6Ld|K%&WLl@oW(v1@^m3DqM;lq8mE?d8Kd*?_@1{h#BmoTFl6AYe#vjEt$dzPrZS+ zQ%U+lS@>O_En{j)ew5y_*g8y=g_$jeJ5A;EH8y9@h93!=E2Q37xWCL#&7~H*81EQk zG9Mh(=L4Y-ll(wKm)MqHPq%L;{z4yVZ-XcA^?F~^Mz4H6?k+z|+gO(MQ2C%@1R)};RQiKuUT1p+f%;uZNkk3PQ&&5XYO`WGj{ zZbHpN9Lm%nQKlMd`(FE~hDY~s)--k7D^*~hggfz+iAb*~Dg1$QIH3EeVf5j$D}Gm& z_3K(6rVBW@;M3g8cRH^$%tD%#c}Q@Biu4`lS0#x^V!eZ2Xn z{yb?%z|WtLq`%7NF}b>ATO6^|wi{v4-X5EWP&mscC(B)%K&tMh0sr1(cI(n^R8%mO zLV>d$azq?+_KAXlM_mT<=_1u~X{xfj5iAEifR_!3Nc-Xu5EG0<$LM#E{F4`Kiq4h? z@ki@#J1=lF^w2lp=!bVF)5Q$<{u&Y8+c;OrncI=98Gq8Xvs+tHsIt+b#a%G3m(_;S zhs81Ym0+5R-Ey_fRRfO`0%vcUbht4WHGCd@NIZ7^zc8xInt|Hny69VDDsO{~%v{KM zOk}$Oh^&Iq#il}T;&&g-%HaW*z;lO`ke=vvC2gtIh6Z-BT%PY&Sh``8#L2^HmB|Ri z(^<3~sO{p(k;0gh&n>r)bkYRy$hP$T>iXj!Tce5q{cQ1~g}8^qgkVI~`}XBpk*#MjbUDt7BC1B=!)hkV+DIdz~e z?tUzLP~CEDqO{%F!l9s`b+348*`o*61*y3eEqZk3{9H^AsP?NL{4r9p9g6t(vzt6d z{GQfX3?ho7vYwUc_{|TlV==EyENH}f+)Q`S*wTm{}6 z*GM;x5TqBMoqE}rV<1*mCaJ_z3(N#K6rLq^0DgP1uiidr2tDUyfAjaG5uJ!fk2?P* z7yUNttSkC$`AbuySai>e=jGlpLc~#R&XWzXosSPk9{Cvd;jiWWdA^^$Z=v2En3v1g zAXCK?m!I~eEK`@S+X=wO({Gn>AJhjpMbV&mqX*Fqx8vP@Ex+ZR+WiICkF)`UMARA;Q>w6f^nqawzT{Q54B_^t)!zL8SgK?w%;!J5m_w|4#ZWwya510s4Dki(vR4m!3m@IlNKZ&Uu>4Kd&ArNbMf!^*)7!bD$S2zs99xU zQ4xJ#^yiY2A)r;urUJi&h?jRozcJBUOrZ!r2tgoW`Vbfa{p-!_?JP$KTX{P+A;^X1+>?Oa$N!seydcGLEEWR8x5y17sE@Ycha=uc%PXbUhJc zV(l<+!;{{9rRsS57_-B&j&jZtiUOI?@}_q3>-O#E z1&_Ycz{hQLr>WW8i9Z*5_wc5ZTUV1HD7@{2B&L<9<5Z-#K-34lR9=Q&s30TPYxA@& zcnC zraI}=I-BIalXhE359KbC=2Tet5Q;(t4i|i+p5WVO1?yb9DUh@={ZswoM^kkA9!&Qw z6)Tbnr$Q0Pb3F>_R=asobImhJp*6SeyhHw*eWL8_csnxgSect5%f%D>S0(I0~I!E|xD}jg}B2*UA z7fKdW=Pz9FwbsKOblLh9fjH4WNU;EABh+UEM2WQb!WBEw@-nGy^XnW*&1<`V2}K|R zXZLajh69j_LjLvRSuK*4xJIoP$1N8#YF86r19yX>9d0GN^>dTWK_Rx`xI8g$FPVqf z!x=y^25Lio>Zhx5_H1pWIU5w)(7HIJ-;8_iyZ3Evo&U?1@`kW$WjZHnr?cSkA?^32 zJ;#F+Uz(WT8o^`%5|$|CX|=Cya|_rpyyDW@6-e6)3pxPcB8rp}V z1aKC&+tvs3thUXgE$i1)v$L}&+|w9WtWZFv8tV2By24mBXlc%y#6AiN^2Ylij1$-h zB1(kra5(~qs-1QMWX~&I^N?U=3vruXuHhY!o4q_8O@Cx_{RnYm0IgUCrU7INRy#$J zbVh^??M1aD9Mlf@h!cfFi{%V7cwXbidKR||^>?%&P7wPzG>+`L5z-C`ItE`C5QP0k zj1;JN6MMlD)FAXdBHOkx%UxQFh7Z*ScDkow&*Jy@BT)n9$7`!hVAVsD+n|5y8-h;L zJHi!k3_WWy#F*(ZGWT z$Ao~LAD{KqczW_($f)?H9FUPJDp!_3M}hRH^F&kk(=q9vVNMLzF%T$3Ia`xqCkU(! zI1b?jj)jOZz~+gb0~RE5D*(`Lfdce!@Df^D{lc4UB)zEXM>IjWGM%%=+is!$gy=Cz zS{@!Z7$8MI?E{{{S0^RCmfqMt#+T49l&c`GjKW^d|!YXf^DR_2OSR{Qb=-^z>~l} zjBL-F#9STb&}6lZCJ1$2DxEgg58%Zg{yflTAz~EzlxI71u|;4e2-4lzUQL6^B;iRj zQ)AM{^J-blNWEO%Qg4w9=gtS93)jHG)c+2gX+ZW z?vLl>Q|ro8(rW5PkoEB1JoFepQ8dHp+;ivrm$EYZU>5|n?PrVZo1r+h+L97*$vk~J z)uDKa@#_+y1VAiOr_t!xxpf2X;5_pX-@bwAwYeBTxK(d+pMW3HAEm8Bs@*UG*xb zI!>ypUWXYAc64TyOe7}p@LDwZ6D1ckK&q_;mc?K9E#qhj2NoA7k}*fvI9 zdEbU>hpg%>bxCL@D%m%aEMh6$J7ETb8BhYjS$wq>Clo8~XD02U$CfW!cIi|cWQ#xy zKm{OB54)2HVWnXtU4-9+$ziGfamCVb+4?T7kM+f47-;%QPBoIFB6U|li6LjEFekXTslM^f z4wlY-#?|XQ$K2D-a%-q8=@odDAG5~xWrkaOmD(7y=uhY&!EPYp6Zf0%Uy~O;y^%37 zOe~ayw3$v8Ww75wj?(S59M>UiE@&=ipIIFD{I~0`oYD-3g&vurhz9ukiVG66Cd>V~ zMC@OF77~-1m(>i`97*Ech*fe|$&^hW*8wM|(5pCsZQGJ!&ENLvY3LPd8=9JJiALWK zzJnHj0vttncvZqO?E8Kxk4+OYj=aO|%xu4NXVp1BJ65}ts)xy}L0~{oVG!Z}r9Ed} z>wCTy5AS;%2Aa0C3bAOXa2h4=e9k*qQ8Ov{#lF+T@_mien3P_JYyyp3Uc{lstg$|~ zy{FP#ONz_g7RgP$oH+7M?)ybOflHkeADxrS-aaksL(BnO<#}(*-4@&a;yA88}@Ig{e28OG4xSwd(Il!RChA_HNAb>qqB2FPPF3HNu>R>Whd+19GkC+ zInF832R?jA|A|NILKO(%>*v2C&X;&iiSPiD6Ps;2Q_=fEsR4olahy*;rdy|HHQkgn zV&swWL9_~q_Qifq#Z@}va=zESl;YY3A|GGfdb=KiqBX+WK93%)MFW}osl3QkjErDj zLJ@_ag!()XgE;VV^W1clCM(J-YHb40RwG^*3<$w0+S+!&90f23TRxZ)PI|9J5P<_5 zhxEj%X1Bl3;g$23G*{Pr&}6-@?N=&vN6HDd+Q+-5+NW+v@~5aS_GFua4S%#~NzxMj zvyFx6L>A}d{iVIqgS#PZ3YJk;x^GhUPo7m=qf+pc z>zPL;@~n8-@geO_5hsMer_je@X2EwntP63mpw=D2bA*C)5$-<%lf_^!f`a|EeV-=2 zd?=<3CSH?06EEQW9D=v#;IU)3Uk9Dl)YQCR#R<WV$$wbuWQ^JW1iH*ge#W zBvJj1A;epuESPbmiIrm(;Q!oy(0R{^!f%^{;{$!Q`@P^-iC+o(bK_&1Y zsU?ZF2?%|1hzaVr&L7cCs5I%4HO;CYzUkLiR7}08b^JJkxTWzSVYRcCt$&b6{6wp> zKvwjltm|-M>pZ`$QNVUid3x$b<50K!S^0gsDr(m%MEd5AinxAdXyU1wzPorC4PwC?buy4+YbHAktu(zE_tZNePfS3$>hIwnq>aaae z+P<@8?kp2%?M{jQ+u!Hgvad>9;s#?s(>!ce`7UwyK>W=2eqGYr9B_$$k-QVfeD32`h@!b!B+0}8Jd;0viDqlj+^re6yYW#p4ukjln(c=2!CgAZ#IZn6{|C(UZ0(sx_yE_ zrLE0i8YDca5(PwbSfsf!yh!MEYS8w5m;zBtHcW|sojQIYkS`kS0H9nO37*++83$QtXr&A*yoew6^wb)a(Q)4-(c}BW;`Eg;Wy# zt=saAB3y64jMB>Hw-g(Mfj7}3;#F~e0Zd#2rh?B{VScM>|1~N2weEA;dhx9@9v3qE z>Q(AwM5`FuuW}@~#Kyex^peUg7i<0LQiRo5Fp2q^m5~ZsMIaVgaXhE)F~{vs5}K1J>38fhDSr}ui0MCE1lL@pgdoU8MN32*`iJm1 zre+ z{_z-V>C@)Nzt$G1s?yw`Pphc zLuFb~5zsSnUrkk(W_SoZf?*_ZN~}wU98`?=Yf_mG`kv1zV0 zI%VMKD~dgD=FHC-sdze)CREd3bLz^z-a9@q_g^;5r4Kp>E#A5OM!50;wJ9e#T+qHk$e`KX+)?q)P3vdZY!E$*5-w|Q zC}?Y9*7nfTUQDdg7(oIZ>s#}2DG}}({<7to|zk!ae}pQ-s`{JF@iWf)~N-$Bkk8j>=aj=f|^j4`vm~Y*LLOLAc@WQ(0cS zS(wkR%e_+HpJx6&!pEF!PpoU%E2e45Vw%q7dlLECNn2$})~ncjir3^+@3Hh5M+>I) zXM{&@0$<&VBlSg7bkPe7>+3eqq3eUwy$&OS!u^T`x5vz$>=*#a3Wt>Sd&Z_z9DAP? z^t_#AY2I*;%z5e48F=lxe^Ec8Ut>NtA_)#9GqVbvhMG0)h35Fx{;mU$x7i<7?3-WR z+T!BkUuLd}cByMMeh4XTkWYIGml!K@!=Y&430}RNvOgk`r^#TXWLbFd(0bEx{gvxt zJW_)?gaVSlxSj~zr9U=XP;=k?S(BCf5ubc$Mbmx_k#%;k=@spRxz~&u{{4%n(3#H5 zhrMIIHd;KvN>`y%J8hQV^<12F0(o?u(g!vIZ=ZiZX2+W5#|KTW5KX(ZM!bkd&fL-1 zv^{=T1V<15yBDU%8Dt}>nk)$M^Y+T6Q=3q|W@Q^hDM%lp$j#DJn_O@<5|hZ)rL<21 zEG+&GjhF`tr2qdcQ2BS;3+<;eC0RLkE&IfzZ14*WqKaToP}ZUYHWvNyn$zlBS9QN! zWbyY2?mbv};lXzI)gU}cz`ZEA{7bqiy2|yCOdrpw(>xqR^QLuanov_`C4Ym z#g2A-lzqyvKf95E={7Lsx)*=?x-!qGV8f_jK~Hk#uk|i}wy^vTEt}932U-szFAw>g zVfsmuv(U`Cp{m-dz4|{BTjssTQ{@tBVfZ*3`$YB;H)wlNfJbEN+U zEIM7c(Q^1qoq6=lBC_c%2$dOUEjxT{Gca& zq=ez9n6I+IMOD5s*(!JXWMST4r5Y`-2N{exaHp{02>aNYM8hBVKp ztNS*~-N!rVuKp24hXS@H*(ok2>*|}&N>GFclmwNM&d}pU>iv}q(r_9|fo=@8sZ%f; zzORlIhc3EMR|+e9CMZ>aCcI$I<85sC8>OsTC_K5K@n@Gl#%COjlwIaW8O-y!>hvn! z?`gSUhZz$(b`^i|ZqGk$H3%6BBc*(n7HeoU&-W4q(B?<3x#KvoywWm=K4oX?K}Ekm zh}i4ubJ!=>WfAAJ(Rk@>{D%L7DA^|bJR5M)MXgp#dv5ogu|?)%hIj*fu*Ocn9#V0I zqy)zwg>5Ylk=)g8g8M7G6n3!xy2;JK>>GC|udj36YQ_M!4W0esCm~sulEE#ido;V~ zwSis_f30p#F@uF$^(>2nNOOBtTy{H$6WJvj%4VJr9tbbZFeJf(@`f^EKN(g;MnFli zk?#Hp0RF-9PkBX)KzVdE)t`My>?;3WwB%quM@p+74!+z=a8dqyRl+@_!pT|5JKphg zQm|llB!XkwR=sy?VddB1%Vpu#|9uDiYS5-tuW0r~p=~MC5rxFXd#CEl`_%vR6jlZP z?;onTVoT=T{GV53*@6q_e|{@c{lkJ`>A$z2B8Uy!NAXwR276k3r#8e@uP2no{ZADF z?V9(I)`PiHZpr-fGLit0F6Yc4sYJ7iAth4}F zl}%Ws2)l8wf3aXAIjekP)BsjuG)LwhF?3~+;a)otT>xn=0x`O|d%DxARM86#Xu>bN z1AzxndWf6!!?J^USu_;lM(E*NX;2al0x8}9Fz$bTEH3S)H~Q(x^l><4rV|&7j{jP; zDq)rMo2s$;%`GJbcC3~Lhfs`GWPtD?v2kUHe*vE>1D>fgTpa1?@1CiGtf??gwwy-z z5j50LxbmUNI`#71C!20tDbkXnEcZx$&-9wV9XRfRvs)G}o{9-{9*562HZ%<8Ei-8= z+E|iz>#e*B25`J{U*LwcryJT!9d9YW)XQhqAhkM7>=9 z>1mhDMuXN(bQjDN`oAz^7^Dg3=((Zx1AUrz zr3Dtf7(A^-gfze?sOL&SP=6T$)%MfHbdhT}Z_kQgR=)ZEXo4|=fu0}K;e;bb<`agVB_MpPfs<^k!*CzWW{IvFO>&e zo*wH$&UJD%#rggH{e&uB!E#A49jD)6fEBV>lYO(BH07%6nF#WsVYJg6gz`&1W) zo!Alzp4YJ_K9$4tO4n^jTY=0|6w=!E$9`%r4p>Y5gKF$8sOsZ?>PZEG%=2>`h6Nen)QEIf_T))3=`{XdLkW(u6nk+zlj=q za85gkhI??b|CIRs{zA*!2y_3;#`4mxv};SBe5`rr{8=`vob;?CAwXRltsrB@XZ>=~ z=GyYKorXm<+yNY-^pF$bnZ+YT#Ahg}L$E2XpAY@`wi;4G$|Ew84{S`_nWRNFzrS3z zW+`I>p@=&L6*;4jkeWcC&b@E^d-kY~XCwOz^UMTBKpA=fKlRT3wXl!+)Gb0+MP7X# zxw)>q#=N`{7j@XzuD#@7x`%_9VG>Y}ypFwO^07TY-XE&>OdH^`!07?E?!Ol+%8j@s zk7UinoC&id2RXF@LrXEHZV(hxeJRmrY>9ArlmBcXIF(sN30I2ztcp);Y;5Va2@sVn z!Sj3FwNf*aJ^}DGa_$^R@}txX`#&neLDGIZ7vwzv%@I<-4Lk;-g-9(F_2Iyxf%F)Sbk;ji$yKWV>SwGh?OZlm(qHsK4nL9Tj4E)$RHszZYlOs zH(6-Ik{|`l9A+2M1-~lO4;)xRd=Edmump(LlJY1C zybS3R5&vi5@KE~gb^SU;k?})tghRLs3Uj%dVof@5rsf5z2JN_ZUk8Cb6YzsV^1WoxrN}Y({ zaTJrW?d-8;x>|O+uH{chdCZ~YA_ItlQX;Csp!(`ABu&=rE<4C~x1}$&xd98sE`got3zXO>!F{+?!ORxEp?4e^RfJcKF|j_U40waNrM8h}ayJJsGGW zR>@TvljBYWjoBe3?b_K1l4H2kk!7B9b*y&lae$1EWK5KtCcwVq#tZdQQ zKMb$=zXv-hV@MXDCMHm3#9e}CegCeq6nD-2`>)otB6Ri-Sj!-{F!6`224|O?(wm|t zyxlHap3o?^J5TicORCC}2e06m zRWTetElzH-3OiQJnGwt!`koZIyC(d8z{i~q`afT9cs~K;bv*ZYJ@bNfn6WrHk@E#t z=WC6#U@5^+dbI4LpO$N5^zNbQ$?F`eRh&ji^*IUl^O@$=t7W$s< zAT%M>vPV}-J9CWphJ%IjF%J2n%Ld)Xm7+g+Czn7SHwg6ib?Y@|H}5yKb0RWgqD!-7 zoDBv%BUEg^w^%mUwHqhq<_vozAAcp3GgsFx;mB2VudD+`^*$JHcBn42nDnwGNj!p5 z*Tf&X8R(=&ijZ`e0e80|rE>mrkQ}GYJ$~C^Rj2RTpE?q_d{}gKHCV<@Oo}FG`j;rC zWk<5->eIM0!mcb^XcdVoUm5PXU9k^HXwJ z&*2eWFk)ApIB6l5GSX0H!!pO>qK z*vS*(6EXm?sk3C&O%?r5=aQ_1_u(iyqViO#b2vWJ>)F}U32&1#Wnco>>m@JzP>pDZ z(@g#@+axcu9MZ^|%`Z$xXN!aLUddJ)o;Do(BHvWlG{=?yp>tXEm_@%G!#3Q7E6U0W z@W2z%xx0HZD}Dm$(_t_dZXAZD(qj7z?b8j{c1(#dMFtruy9|EPAUEw+(+~RPM-kd7 z5ukCJ_rO=m{qNsSPYgP$<_zfz2Swc)78r8cPD;3`gRfzhEGR?%i$Oom$`P4zW#dEl#!}b{~{!s$W!{Cu10&^Ougnka%L#e z;)(p_*RhsU5P1@01=m|e$93`WTsjBdHp*T=Qo9;8<^Jn6ReB(GO;*yb(cB=$zvuj& zM!UO)z`$z-G7v|wi1JYhSaWalhWy3XKkp3D&3;CCl&AiFg7hIOufBwpupyksB1OkC zimJ|=)Ybq=;P>NQOl_63Df{JIS2go>lPKx&@p)i)S+9HCbv07$A6e@384bj_8klXo zv2=NP2Egj+iKote{dR2+XsA2WgPWexe*vr3wxwwa_3@LCY1J@<3?XKm+1WB)9p^GF zlzx-JI!wn;EvAl^=~#9-B{o$VXISKvu$6f=KmG7$?n>Fy?4Pm@-YeLAk({!lZFD`A zFyKl;$ha5(+wX8_3pd(0q%~UcHa7|Dhy$CqF9t=|>woJ6M`)QiQX4Yct)gxeAbtl+ z^Mm0_i`F?Y^Wyvhr%c7b-KE(H8#d&7+J$?GQ|srxiU>Eq&9U>QW!ek9r%uR+jPe}0 zsQrCoR`+Mc&lzO~#qTnm(U2yygT3_TCa1M1k5+NRx8dGq5u%*+5lMYHLSf~jx9Qzx zE1&no$8Yr{8uu#shpkw_7{Pw~M^b?~Z%s=!>6LB%jS$6FVjN_urW5a~Q}|qvGFKO> zbO-|vJ3a8;$Z8?$Mt&iRf{r32*hmV$X}=w%pOEqUZJ;yVVRAnN#? z%dYtyv3n1QwgBvZC$Dtf)VX!QsPZ)K+A241mR5(YWbx{mPcet&e9E6pv~Q~|=8YTU zDb4Qh6p0QEmp>U(OlKw&xa`{Q(@syn^w&I0i_^NRA^!BNo|m@k@Rc$98Oaemq`u#nAFbuy(7RE?v3Jub z&vIfIB82>LCFX*l-3_T=nyw86YS(hXyr zoLNd!MsAbaR3CqQtWb6H9Wq6QCp#B800py6w_liZVG>@&b?8 z=xm2krWKi9$8^Qp%g|xW+|jbFk@A2nuP3ZIum}4$jRgPooT;HFKO}pa665}`c!VtH z)99Bx|0KEcT$6Hf-*2a`Qnm46z2*;3&km&uznGL5CTwUg7RQ_#h}hi$7qIW#VtSJ$TA00}fI#YOZI zkxMc5gyeCL4y2C8!F*bbfhT^6IkOzfrJ=r%mWeygtPsiida+UuYO>uCP6#iEL-*o- z>o=Khgn=gr2+!g|+PglP6u_8O8{!68 z()FQ9VqU$v;!g-Rf4mj%s`6YWs-@kp-fFsmKkr0w6vsLPH9&djR z>!|>q_u&^)e%{=jpe=r~m5Db|w`M9Lg88e)jVG8~KRjScWDS=?9Ui@fluy^mhVA0? zHpW7uvQ~U+8tQ&gfYn4eNtMDe2U0?Rywqpy$R#3-ASY(Gt1T-e;S+6R5EwnShKQ=vCJv%^d@t->2isesSsEhB(xxO|;q| z6QI@$ip zcp+Ev_pdFLr!_Yny~E;}re86S^GYaXdu{vn&ey0hT{r=TzO}IrmO7;Ge}Cy)$tMXd zr*FbE;ya}lb(Dqlj-Fmja*Xoe?ZUFI>h8Ur;5n4z{ zM3X}jb87q!M(OPTWe}SHGB;kuzS@YnN&bg9qbr*SaaEpa4)?gAGEU3X za_Fo{*$slcq2IQDxM&x3>5r^VdIG34rz@kY+b(?s7m{XQ`dz>6bLAPBG&K8~kJg9V z`faW#<1G1FTwUL#$;4A-SeToWkK;a}smJ}J^ljXxn=<1fz(K+P$ zD%PR}4VH}4-%H7^qu9n{4BL1t6-!SdAfFlx{M1_}g0T=P#-icDl0RC55AnLLre0^B zX!}3oYtk^hi411Lbyk30B2Z9W|67QKK}((|nRK&NIAyd?Ib4#};f5Ctr3+<~bs;@f z^{mpSTs}a-oAF$2=70gjDww}BR9UTO2s3;9EEgFOLZWdn(21R?!CgLQa?-?mq;iCe z2Emu8yf+Tb2k^rnHX64tW<2)=vj7LbmsCC6c&o|fbXxUO1#b?&PmphJn7HU{zIf@% zOCv^m@w`Ka^U$n(M*vTP8o%Ueu+nd5QKIHJ3||W%HD@L-QyU8RP=1ri&#obXan`@2 zKtToF!(5^_u}L5NW0O*T)2ZQ^)j28ww!A7+-K=V^fOcdc*j5imke~WX(FjLm~A2s#yK)SIRzJRg%Gb+ z2}FLkVEMraqzzDMcTI9+nvc3yt50QsfKd3|a|;p-b|kbPGBDsH={1i&e-38rAQSC} zM+&cEjeaS{^nNd2pzFR?nT?g7T*1E!9AKQuXMEN3wks> z=}ijxabE}D><_{{o{usuu}vfukaOFZtzof$3{8lb+56doRn} zduT2qTRPy@o4Pq_Rz=LHhRgoY8n5L$X?CvODY}b&Q^|JGZ_HJ?gc9xFemBvH zSIX6Tc(K-6u+%i^6mzz2B#X+iHR4>B`>f8bJI|To+05HM{Bg(b9dS%89Wzdi)fd7g z%O#s_!jE`~bU62C4WD@HI+FC3)xlX!UF~T=7>P_Ei)-cJ@U@5tUXPxRBz(cwC=Z|# zW8O%Z!|!Qes%tqoZoc|_RQqMz>cpwZNj@Q2?#&vBkpaHLKQ((^Wc5lt&bPsS{yZp#YOvKV!YWVYY{-9b)^VqS9 zT~Xdlms9*g-mi;#Z(;s}@i3{7%HWq%S@Ge+hbI8#eAY^SKfkNEvNHM8)BryBbnt`g zUv95l$t%YNaEkIca-ZMVj7)a>$?0Gc#G=85?VQ6#tnjL$6BUnb; z!;hqDYJUgln&D0U*I%ok?s?)qnx?g@^G=O4E{{OC8nYccS}swNIg!?vQePLJn`a_j z1j&*=4%AzlHx?^DTAwn}qc=x?+-m0yBv+19>(MJN>nVboCz}1-4o&Wy_Ua8 zXaOL(G*Ro6+_3SH<+HP&1;N`0+y_S?9y53+Wbl`RbJAF9!K2lp+T9=@W5TB0I=A8~ zGhb1vcOYFK-pjnh3rq$Z&!gRRdag5~2@fB%L}Ocn@6zf}P31k7qz}JvDhV_>AO-5; z)EaxAFiGb%2?+_W-@Li!lo8MOCCVzjv?NIQtgSold${xHS!(j_AjiOW43Xzl;FZ?O zn*L(CU1w(JjU}-y{dF4MckovHjvf`nsIa~>dmM~I%G~F1knWXn#R?Ulz?sod7Ku75 zD9i=$9}jO#Rt$ZSc4oWs6%2I4uE9VJnw(<6q(a6Pr}Sd*2t#hI4exX^?O|H#8kiW- zAOn;f`_nr#-`zTP6bp0+f81?mM1z{-_TY7&PC9_`a&PHy&$BSMMJdfNu z&U{E1r*e6v1ni)Dt5|x-|JBf+Zog$bd-5V}-VXxY+1&?boB)@a><-OOB5(HQK=tBH9;x0;zg3VI#$HhotpwoXOyC9(h4gto~& z9u;oI^LFb7etiz{(M=X*&ga|s%-sp4dZZwprP3MEiYPD~(Y_hL+|dH%Q_mnK|g5Or~Jb6mFoR+^wqK_P)L*y*jXrz=dM&(l8I)AgbB$JdB@^lh0jyq07$o)}q?<~nT zYJHm{)l{ynZg3L|b-F@rf!YG9!YiWYF+)y?H~Mg=r_U{Y@at>O<{MvpmRixgbIPwt z%dvC4TFJZV0Am!Wy>hoyrVjPQHqniWnFO?ce8F|N9+DH6VQnB>Jr0qC$0@e@!So_p zv&(Q2aYl)Ug1t5uZXn`K%i3u~{#phd2uPxBrj zjpZCK)9U5ycOhi=;kOIX7q9omMBQ8BJxuI=!S7rlIPq(eZ$RJDknF{?=wWRYt*(i@ z)?dSXlNvpmTdYIz!8bCpxwWpyPw8l;ANtR>BuDi$p~bsH3^1XXNdkIdD%(80keC>b z<|iykB>A=PJh)yQ*RJ(la(6EVI)-V0kW2v(M4|y4XwiOcg@T$|6CL+ zu$F$&_v;zX(#<}Ss2lOWb}WGOFOIIqv+Rtc+3lvZ%rR`PB}HMs&KiZf7c6FsR)KQD zLW>!G`wA@&EQXOI0l4|Y#Xds=j^HB&#a8b_{wIS~p7zy^k~S7DX2a!GSGImvR!em{ zYpt1cQ5fJ1%%+D)nwnjCMCAE$G1m3a*jFV2R-x~=YRcB&)R5SZaWgzbUA}!~RA0^L z?$#G7ikyau&XS)?g~UMaT^Sx9V|#syVf?VJ`))59o9X$Ajh3`mMdyPUU-=z}M*PYy!E)ftkKgmI(!?$X6D}yO;0Lc%*fF zfMq~-E?y?L!zAL$#*}k2Gfp$>)5^6sl+ektUdpcH4jVT5x&g0_zK`2-&B`g-J_fO- z!^h6a)|8PKqV9FO&Dt(ueNA5HvA5#q^(w+=NC7?E8kZFl1Ow1{RrU6uWXSg-^_sVO^Syg4nZfGDuQqOM*(4qYo8~p4*72HHE{e_W zJK=KPJ?k*PhA(|~ml&i8`m2`v1DJOoJzSTnTAht#jx^=)N-UUYi!)3xy6|M|TQ4p; zCeM|Dajnml-q*-ps5BR0M8iDV<`)G)kj{w?gu+U(J#=8!Ijy)g!tfwaN%#bgM_d^N z)*GesUF$G#H<3kr`(ThFToDv>JhT@A2zg~Z<9@=e?XM(B%s(e45jIDDbv}EXn9F`m zF$o)~qTf83K(_!^kSV|5$B4D?`J~}^lT7B$<@DX&Th$eM#@qE>_*rs zKZ|V1?ZzCAfl_DgEvQN%8{UU~;5XzQ_Bl4tD5Jx0AoL>oVBZN{*#{?k_J3-wIBVWw zDp`1Nbv8*r>{G^dm=rlS>vEpMEkrN*c4T{mprCrs3ixJd<1RwmZ+2u!sw zUGjRlsIl{z7V$|g#eqqaguSOxy=HH3?N|Px#1BpVaTZ(fc*O-jz>h)_+iflSs=XO% z*~(g4cnH`~4WJ?+=_m8vPT!C5IO(N&FD|3k-@DI)EQ9y9bNI>k;XA?kW17yI8Jq~xkyLvE zp%T4*$J6$X4IHEsE~94*)cEz0VrWT~WMQ^hrWTRJnwrrwR2y{*lN-zv_iY?!D9 z(T{Po$3ZE_NxKMdL#%smdv$#7#S5~qB@`i(mCVSN{GHs| zOy>RNK>}3-&*dx)2X=$rt%?9`{UD@>4>; zsmWV-%Qy?y9W8|y3v-H?TW97n#Uz(IZx`2dG_&2u=TYDlM5X4 zxC{bxv_Z1G{n12%u!`s&n|57@6ST}_^5QWy>8AOioGWDXpw|4|d|)Q|auH#0-sZK0vCIA6IFtmF2sUr_ZvZ zb0(Xw+`R7%>-=E< zq;#do=6QTWxvmitc!zoa2*7CW{`?*J1h`TpQ@dp7=y_5Nwq%F zKYye+?v*{Bpe~(Lh2XqRxaYvuy5h8xGsPy${9b?g(z(moYi+M0e@sPqZn>dvr7y-b zPq~|u^RC4s`5^F?_pTVNz&j;gToXpuMWH~D{Dnhv_WGls_F(!g;XSu5i=YO_;eq`L zj~7IZV2p&{jpyebG+zXDXE*|rk}5#h`3zrWru_=@#dPYXSK%)$t|ncQNVz;U$h1;Z zT}wwXq9oU5)x}LBCt|aGscW=+QAwmH<cS~ z)p2rS0~m0^=Z;$-dhJ|3w8Eh>{1TvDXe~`fJK=TT*7irTI(?qkSgKPiY;yi+X9~qo z2QX4$@dr8cVug={Sf_E{~{lhSE5of(j zcvS+#Z|XKdvYP$&ZWvK+nm;w=l=X$p~^GMPA_Y%+v4SbJuqR5YiE-t)O zq`arR5^3$mRE`<8gw$2-roLHoy0!fJG1WD5WZo)=t8M`QR&)BPv)4&%I26bC1$7g< z)&mR94f0+)p)P;rzL}+OR;1Ke-bw`XlI_R4`cLZlwhcdRMJz8)g{lfnkplXFeh#Ki zL2>zkP{*<3FR>T=wzoDm&6V|k8QQxp1f`6tfcedJ$$~#8H|_r!jJ`^RFD@=t2>@P0xSSf%OlY{Ku|I%ziZrX`K_i=4jTgzm zGlA;Wcq=0l6MtZpfXBj^K#&^zO?Gqg?XoDQU^44S79$Rky5L-f`3N11;;mD^&!C;^ z<>@Y?#}v+Kd&Pq8pKz9|!&t+~U>d%T`#3r#CQR?%y&IjMXVZh7)&SfO4~cXrRn^p< z4%k$F8SRH(bmi-d_4W5Z?*$U3bfPG9n1RzzyZ^7rl1Gih_4~(lCXP1s*F4=ypgr~j zU`&<{uebrStyiFMA8qjme&Eqd-@K12cl0hPHn!01S9le?0k|^pA+`C>Px1IfL@fYA zr=dhv=#@8i_D_o9%^@9gb5@ue3rkA_>o2yenPwhK-x*LGVq8u5q-$@DN`Rzpov*hh zA+q&*ME*<3rAxWu_ndj=t*pl;%xf-sJTl-y>bh=(!p<|@Z37r% zgM*QEHe)o^Dd2NQ&$z=QP_P_29O5>5q8w9}S3S+ofvGOFp!MCmQ2@UZXXtbXbEITQ z@7IxD{2Q0`eYyM&7y}3?jCf0pJ`K9Z4^(St-U|Uwq)g>b@BpJ^NPjF%<(Tt&oG=Wf zLC@5Tq_W)Xa#J9n4t6H?v<<)uk*mS5YJIX8dS;GMRnx z2U_qn><@>OvAHHO4A$wiG*_3c36j2O3mo6UheL5voe^9 z3#ZzhA260kf>g(JyGuyO1_-O7Ez~ z0G~j2ToLYFR9+UY*?3U?ImDPV8rs?@lPAgw3|!pHz%3(7M>Zs$^l*)y?Um3Ogrs!* zlIQkPI&zMAM9P08xa^G5*lJl!f%)QHnA4x_OB>YdtG_(r?Khj04bd$M&bI##d;f_6 zuqIYk`YBP*8oC~f4sa~a_5Smg&Gy%27W zK67i6rZdp-Gk?T|5=-mDC$3@#L`50HHU+%$>fUTM$@YxWx3wP8Z<5u3Cy(0o{GBZu z7wKBSQlXCGy*{}>IAREv(8^?O%yUbYdE*mnlO#XMiO<98it6pAlwkvIWzeqlOlqG_ z$hl{tFWuw}Kz*m-GscwErx(CcR@z_Cg?)~`a8n`i-b0-#>Ar_0U@mKHhM5sA78~m- zl(S!h>VjAHpGGULE-t@$jNKdbT)rt^VZrnCsZC9XMF2@jhp7IgB+lM|oo=p!EHqX&Z)MH|3&LVT3R_<}mM0&nzLV@&nH3rJ z_DEep&!Ip;n{^`tvR^y{On zY{ppwu)!dWoDK4jDS(}-36r%$Cj=504NltG6=2S9xB>I|^78Vire`m|P(Mx7B0kmY z6J(Ezrt+y?&aWz0(0m~8=%}#Es$Ux3l} zZ1C6m9RKIes1vaWnr3re3l~A-$#=1)s*v$kTrFxOq9gPL9)2mZAh&Bg@Rv=xvZCReIiq7XSpB3 zdEL}XHTd@B+ms@lbrG6c@}s;b#@RQ(=?>-^4uaOU0LKZ0e4xWl8t=w!g)U%=LD|0M zWINIBnC)6(#bS%lsH&NDKkhs6qm+%7-@f# zh=zdApSxxt&Tp$pa-1U@;7UUka-sM~VorD2R#@ld9ItVyS?*HC2Gxo?p5gE(Le_A_ zR419gnUM)eQxcf?}>!+gWF8$^9%8!8NXL;$(xE#_tc8`o!CB(Z0xyE zZ8itjy9z~u$49K69?)eY5mU3X9;PJhhqFXy%UkX;=5V938Lg=h`7Z^L4}mUAKt%+p zIWlyg2qk6Qq3sPh+VC7oo49}tEvs?%E-WIf3u-x`;?H;UGgHWi5IGeJ^E|Awq(IA$ za4bQ+*|W`AMcSWYB8m~^#sSgG?{R>q7q*L)>=o~0Q2$Gw2hR+nQ0vIH7}RxG8VQH= z4s#*yCJcpV)LPRc^NIaz}>xO&(uxO+Mh*ZHb9fH=dFSi zdv3xSMtcw3!#Lh`Yd|PKn-+qKU97r*701$fe=+zO^8nr)@pPosFS+ORa_v}OJ+n|d zvcld@2`#*bOBsiRED5sUBy@CW57zU&QKB#?Qc(%$$pXCqiZiEFjJq2NmRICuTR5eo zw6?Zt$MxgTudsB~;z9Wh+&TzZvF!{Tj{U(DLcmFH9&&3Cum`=z6PQ{EAM3EQ;~*&T zx8e@59P9ZpP~@fz_5q zD{xF)e;B2AId%W9fgG6G7;=^yTWoLnI7nF2&Lm(Wz2YoGC&7B9+;QZ_1SJLFTaDp$ z#}J=0OP-HHhmrH~m;_lI$yy)TjU2*0q+$@#YZq*Qb8x zy@t>R8Uz6?i$d%W2Y1{2xrEv?WbOEB5TlaiaGXjnuiQ8j>C51`5=pfQ@qtE&Y#__S z8DbLJnnUjnzI-Vk>p%7E1ITe=WUJLf|Cwy*rszH?V9bwW)NQZJh?bOsOgV;d<_`xt zK2!$@AB=vEE;dMI(`0#VT%k@-kSjwSBj|^nwF=_D4fX}x;4el2J1ws8)=x)qICKvK zWMJ+{!OxKK`?-d9rG~m-#MU`}rKsMb{PxkVUU2ro| z9C|ZAeHThlqn4+Gwn3MrQ>nzKNqGVbCXB-llKT$fo$DhbGe!g`T!%>w zFs-P1U$w@?Hp~A~A@pfJmT*_UV#jF3z0vriW7tSQ@6Q@>KXh_bea>f808@ZSLSsnd z)Od4SU=x{kGFcLzzj1)7Krr?=^K4Z+^Q%t@bpi-Yc8G(Znh*9UY7kltMEDhw*5}e2 zX|3gM%@6pQy5?$XuD86a6@AKCI}!vE3_BUeKm=mB&q3!p^|>E8#^3)hU++K1*y*$F zC35A>5{L*ueaQMpKSWtQwaeUnEzS;PgyBQ+m>Nawwl{EIPM?Xn!6W|Q<#qSJZW?O7 zAa!5>h5z_L%~Swp@|}CS))>CmoNcG-j1R%8fBsxP+<6BJwEFVuMnUG?)IT1BoihI$ zKz#f13kDPdQ+f9Jl2+U1S3$mfHU7o{KsP1Fnb);3+Ce>Ux!&ZKi{5Uw<5r*ZT3P}ev_wBdrM87)qN^@`3v zoVh^Vm1FdK?H1q>Wnv6a!A9A@{kIw|@FMipayy#!YcH)KT2(kt?~EVOh<(BCzBfYB z{sisfi2Kh3V|-BujGik{HYG`18cL1v>lxS43&3!@SlOn zU;*(9D;pbh!;XS+p_hf(;2%^2Tv8x)6L(YW-#stje8I37#2$R(wCUWHkxzC!1f?tjmgh3v5hfS*(@ zR>=SV`xw}GY3b+`Y9QwWr98_5B4fBxTp$(b=ti(AAOvLu zS}qRAcH;AEF=4H)u7(C}X+)4M1Xn+$igFZ(?2=G(H`J>Hy02 zJ7msJ;5$Z!hG}SdJo*Jv^b2dV-9eM}@Sw(zR$J;~q;Y)EnNuSNyOw^=*@$s6GMto zU$JEjW{hNA1I|}RM@QLQUL_ti9;Y^aWrfC+_JP%t9^B zo0!O}5EaGr_DZU^qOSZeO>KK$A<|+7sJAw0k3J9N-T#^9S0T+D==mi*=sCi9BcGn(X+IQ9JwW@99o4 zg^p7wIa^y>8^6;u2@nck&O28?6*$ml8v-Hk^6Ki(&^ntO#w#=|l%fHz8W^)AXH@po z^L++h%Azxl4*bHu^TMvCtsMl8gbbZjMpkB~k5;Y}kTMg|3Hm{P6r1Mz6VU8pilfdZ zt)_%xs^@?vrndjGGpla2>V(~is%>g3o!I**?9FI_4|$tMmzGSf+E3_K4Lk#N&`$k$ zy2>SI8RN&~O2R=eCM20h9G}IpI=dz2U1#c^;1H)#r}Sgm^U=G_aa7J(jPmCkUAcE) zU{H6Avc$2Y&3DiqkIpyRvji#!hFn5)NyLLtIX} zi(Vyv#3{z<>~?gYA&P%@v_@V3^T13E%@>}^rN0oQz4qp+3ot4Af>PGjxtOtV+=n!xJZ=lyb+?&E zs3UN&&n@p4WA`c^?lY+TVJE{_|3zpo2ZgSz0X@n`U4{|z4c3Wq4f^BMq>0bWg{Ag#q~do0=QrqpQ@Yj$T1-RP2nT0C9+P zzjv{a{{LVY#_Z0>-v`{(trrOCnfF*P-+6w%YKO6Mv@xU6qsXoSpf-i^;l+ol$IF_g~5GpJB4wmFM8C5A9jq zVVu?Y-(cX2#Xfm?^y*hYWcorv;Qz5ATA3Q!7o1Ps#Q6UolZwHs5R86vk@0&@I+4%c ziOw*+;raeu*&Xty_%!WUMguUXuW?aT=zJJ;}!8%(+K9wznIE#j+CZvGYH#gs!{d#fJ&5w9E zfeakj0qbr(9=!H!Wo1et)*oU)R`DUt1&7e7nCyxqdI%KaYyJl z5;_s}p(1QZ^CaR!D(aP;-5a{{xNd^Lf|;%K@+LaB#6@uOU!LY7|>UVZP7U8OX69 zb;*=@B^6=5<;{qXovoQ?jD1%3J}ToPuK$~Ai*(>qG=xeFsb21D*9i1*aB#SfNsFK= zFhRias#!+@1=CM-?m%@IZU3GWIk?Rq6;F%Y(NO+AD+*ie*mS!lDz=q38~-_|xz`_j zOEVpSy$QTFQnPoS3HpTx?~Vm9K<8&4NdN7!ijy;-)HO6r!5FyWpW%Vyd{nSuT?YxA zM^|pz9oEr>g`tTK2520ioD!N@X<5Z>10np(Z0_5PL(th6x)Y3t-a+^8>`K}3zmYgX zps(nj*c~1oK1$@2#blv?!~Ke0{>_LrzP##tgMN%A(|Y4SEoq zm{(0DfOETFgE2pi%xAl^W_hG7q~I->IM@H3$9{95<#*d%C=JOg+Pmm1&A~<9oKM#% zkw%muugyEgEKE9Mxv}5a8D_morF<-pdG|-Sr{ZlJ=jf(+#wxO86DN^|*0TotiB~VA zF`Raw@!fPWJA>TE>NpLGI4IxWlmLN>FtZ#mgJ62E+GnNH`Ubr*G?85zss@< zjjh@2JKsVutoYh`ImLm{!wZ51y2%hla~At2VT-$<3$X(8sK%og6&Jd-^JVEIO2^#U zf2_eVicr1V_26e-GAUyyyFUw~*XU2S$EnF$L!ZC~00e7TTMhdU`prsRSpP zhFvp3cj&QWvm2AB{QHXvjZ>$Pa`ocA@Pr^ZG*X>~Eapp{^5}6oERZ_m9Kxv#4%UE**H~}lq}LK7WmDB`dS7pMzJ!VyZ67Z-wZ>y`B22 zz7y@zI2{I(4tZNzlgzCHuEZ3P7hgT6_KdghdL5EIh#eNPT<`Tp&ro6_Ng>NnvzgLc zkjVLlf8R)jhi|iSaQear9>Kk-<&#$OwSEoH=cdNcU1Zty82ef5+h6B$xt zJ$KxEaw=8jUC4IjM=riWn*1iSvC1`hYYs~|*COQqzccp$&)=E-S3~hGr{l-H(^v)wTb`BAK=cJEX1gA#5v0_l8V@ z#y7<>=8HaHFSs3U7WKVwAw8~RtZ`MvoB6(@BmdT*IoB~v!S$5%eSFylI6degMh~r# z_P2pO0-j#72bcNE>(}7{cgovVq@2!4M1_vK+sam&lgxb*v9OY@JT+2t&A0eW&v@&@ zvCPaKZz34hXrym>Ae|6NQ0iVp_&pzCSK`ok=IQOt@YlqWn|_c~fREsrv8Sh>PLkgp7jdi+eliJFg8REq;&rpzZY*xG ztqa40?g!VVNGHXE&usl&dlva)MY&_WAxOUu#hEGrAm?WM5*1%z3#FWh;&Db0$~Z!+ zoHel*D&;?Z?n*bVtot_J2PQ9zxmiLN%}W!!8fUmq$*1gbUHVGcuSNG;?(!RsOmV+3 zZnb8DC*QJ&Un+VvrRjg0VN5L?2_$N|lTHo-HkLF+iGb5{Uck~Mksb)H1j6f6$m zU!%DKdygT{fd+Q%354QHJ+1r>(IF7)h$I>c(p zs^DSohdKyMP|<><=iLsr)qj&6eA4{2VfGXUTl+GBAzZ1&#^3K%C%pC5=191MW>rv{6?;7k8hMqsh5Y`cqsp->VBg`VDyLY zcQLOEQn1H{dHb9hN6c+zpWyjAeth-H_tKF#Bt)qCJC_GbALp^l?_Fo}{qYsv-A+H# z9?K>$CUvXW&Xfj8)$m{KqShx*;zCY%2pEDuxVg2)qh(tk$LPgCZ5`4@mDk&I{A-NZ zz4Eq*7anIi_w7y-6R3`xDz!4V%iwKV$#gWwF|scbIM{B`tHKUgzL`eMdvVIJ_nGHy zB8Py%o_Dvr0QBhu63HX=t}Jv`?2+&h8Uh4JP*H(@%F~qlIYt}mhU?ZM5HWgn)` zqEqk>gj5z%&bkZM5u2DSg2Ww)_pLAWZiKo6`xZDtFEqibAXsr|Dp?D1wB;4bkWeM>Z!8KLu=fynJtosS`$TYcngQnHnk2$%o5+${F@-us43fQu8c*;0( zYSZ5mY!vW6Fk6h{rL^?WAe$VS=(r(%Sgz$+DQ0L(W4>IjRV;12Aza$Bxi{DQ9Tdd# z;%5TPoma27R=9Fj@i3i2bvvjEH|F!O-hX91<|n4NCuPUs(SVSJ|1NXu>tt(&ySin4 zGe!gM6{#^iwO>;6jJ|j z)nP=I6ltCW)QUcVVQA1!j-=??!7~jXcDJP8cm}bg=B3X^0y$GU6QH95*8ydR<*Oeu z-ZKSVpsnlbtckMn{Go8nj@lkyC#AK~UL(`ko+R$t{W=5@*VChizDSrcr+1{i9ThuV zt1r4d4tLhCgX8J9e4r>b^}_iUA-Tx5NjGS@x+K+;tT~zWY2tj5ZA=KLnuaSYnr@8W za8{+Z>R`<3^FGO<7oa}{0}!Qg{Q$W?diX@Jd4*r!*5V8fntzHu^I>1%Snz!)K8xzE0(jcM{z2P@W6mzTP%5RO!VAx@|wT# zi}d|Daw!L{ACCH<c;Lqah-*in|1{&+nz?*1r?6NSn~((Fp1R)e~e^xgZ!3mWfbC z8tpl);h`!sWLo_8DJODy6N}3huN@-eZ>RB8nK^_DA+M#>dnZJ`Xo}wOM<@FG7uWYt z7`yu~F|eX|r(Y{Ud)HRyz#<#-#tFAy^~cwwPc4)AB@JL_VAt$i%b8#o3JC01Qi#Ad z8+m>liR?@PxbjE)FoJAVpT?)5^zYhzNF&zj?@TiWI30f&J#Oo=0rq~TzI$;S6>X!$ zIbyt~$u9%1tjsL-HcYLp3872K+S(co$1&HdrG4`r5{z#v1BV_BPOLv|BTxm+bz#Pd z6cv$@4jej@ys$W=OJ~Sfh=;y0AWbe$n3NB~e@wRviPDc5VOl_$fZ@@!E7SnGX~nwb zR%#!7N5fgc9TWmS9pGnC@i=J*YMt|rS z@M2GRV|uyAHG}X$FPGmc)=5&)j&Z8ZfOSbj#q)a4I?*j*Xh?z#zale!Ke8)Pt>vlY z2+Dih5nOI8A3eaGhKfp#9SQKE<#!x9O@#pzuiqBP%-*SB0~9uMc)OLbk|u97X6U14 zg@mVw(^LtOP|O#M1`NLvj4{L(cQhUtq|YL(UTVL}dnsiqkBc?bVqEP3gB~{ojpq(jZ#wN>iBZLl-U7pThZHJm)5K~%6qzS`5~f# z!rt3c$*th33QcH!P?KgEr4L(1!7ID+4|vJ+!o-N%4AK6>2e$isG?wgax-9q5)P-Oa zzWcSTgpEQ502p`V?w2b6{^GrkSEdkm(b@b(!Q2S3SL-Y}OS<=4$^C{Af*zK+AGM5b z?aZlugwFH}Syp$AO(f|vmC7)uAAC+y*I53pi1hqLeov9ZSN!H2e` zdsSCX7(Z)6A=F^1tp8p0-zh^Jyn;SOB$UF@A;8-F1gQ4;HqX5%;o&O*%i>&c9 zvEU9*nvGih3X^Lo7x;xT-b^L4j>dH8Np zlev`t2%t(%O`~$9L0)h5j-)54WuuuIXi_Z7Gtu9#mDMAHz!L*R<_}rVNi$^r# zH!;WmA)7-T>72YYRYYJ$S?NISYWfiYSjEHGInDI@uL=%CiMF8vDuL#Dd-j z8=>JUh$OKlyJ0eQ*C#z!EVY$fQ#Vb(=1Y>REPxOoXYJk-G!Qq7$%4Kx?S!Q?M`)5e z$6pvGQ~N=JxI^U>2O4-d?RxWURnM=4Xu&j^XvvRX3hbTh{fT+U8*p~WB5gKrb6NCi z_q3)QSn24dC8@+i8nn^Mcr%I-if&Jk3$J~ZM8P}A2#1s`xG=dZYu10Zml`!o`0c3a z)H&mWN7(u8VM(K*H=eD%FL~MuJanr=AdMR2IJhl(3lc2gHNb2$TL#F;$LMAmM%983 zqII=FCGk7O~U0`f!i5S9J>(|jZ>NGZF100D{=PSib zCMp46bJyPU?v*Hc?Brn14}Be63-9rufktjE-y^}O}8Ktj5@ zRq?zUSJbAlw6ugmWc(u2FIdO~1owcaRl&>D)O5Uha~K+AW1}I2`+yqWZb2WX9Plr8 z66nq*lpa%Wsr%6X!^+xHz#E~`XZrCMRv7{z(8k)yk5_srl3@6X$P3`X8^P=(8brSn zjKA_j>r&JwUY1YLmyiGf9_cc>F)Uu|e!nc$48yh;OtGR~f;qYAyM-T1oRV76xDY0C zg@FHPb{S_2|AvgB*qvqI+~&7U&>*lWP9t{P*}1Iyt2-P8_zaJpKu8mo^l>7etG%Wi zb0mtnLZ%JcZ?`kP9imrkZ&^xB#@nppR5%IIcXB5A2i9e_TDT=qBRYE%_*s(g`E3rn zuOLY4l<2dUU$rC1+^=xt44eX^>eTRtf7|NM%!fuT!S%#qdpue3{RqHb87Qkb zhl-yj!^ZMgv)Sof>rT+65zR3Z7e^qUl^LaZfQdQ&zlrI38779l_V`>v`jvCFmyq9e zp<T5cT!IFIK?i@gRbZGb=TcG6;ZjQ!;erk58PB!1;QTz=+-2l4A*mwFz)=ya|j zRy6w?ZF&j?>uAVRYp8QAG(lk~?vYKx>l-L@ghOckvlbC15eXnRa;I*?j2LZRSR8+iL&q)hVO-zG#-N z=mp_Rn)AtN6LP$~=vwgP&w0ljMTy}JrD}o>pQLP6XI^3>%TLtZC+f(1B+b@pJ~@FI zf3)yY&(t;l%-#ob7t!GbLE_!;^UKA zJIWsG`Q?X`Y`R@zMuVr|K(4gV*dh_^@_%Y4t5MVUB z&XuG%)#d)JqY*(m|53l9a|dB3>Ho5HZk{+N_?57*GbCCNnqYydJ@(%V{=tp@-vm8{ z(I9}M>JWd~HjL)<5xdMhnh8j(Bu*)7Wac z*^+v)OwTXBQ`R4;cIEm@@9cc$-<-=`wp6~OhXz71(3G$ql#JcPM^cESr_f*yj!PFz z4kW@8Q-4*$54sKtl@`!MZs_b^HFpAG&Ck1at5|XNYG6OKrZb@u_2h^C?^>SuYT5o$ zsS)mYhHMarDs!4~-?jx7{M?>pVLjE}er>bh8#fos+DnBB=eu3)XB-}Pd9V~I8vV## zdZkd3qqj;LUA@EcF5A+MW0Ub4_xrRY#Q>-=eckm^JR_St-?_!}JNNsG`l<$ty6hZ~ z&+-(7+ASgkqgkNibQhlH^A|RDXd*Xcm8BCMN1(N?{q)!K(4tGTY6ET74gJesCuUhL zt)LAzalg8ER>We)Ijvmr^zS;P~loe9_cz;7c=D z(|h>PzkWY@t(Vm3WsLf0--;Dd7qus`vfr}F>#3`&YjmyWt>J?#`51gQEn)*jLf0jp zySlo*@b%k^J{WU3JI1*WTg4=_CPM@?; z;55@?<};n0TD8ZG8RLM#ak;Dvg_};2Sv}qLJ+ErBrA4H{C4URt=s>{22)PeSwTGfW z(mpmj`yBA?`}I#w8qy$W<8x?8fo6*W9i=41qPe(8lOOG5-C(o;Dza1f_}o-RphLHS z2OsF(n^*kipos>wSR`E<0V;opq78c=dO=3Zk8|{@_V_F8`ZcueF`TVvF=5G>JnNAf zI_R+oZ71NDe1Tw}Vu8+iBJl=6=KG8B;g`~ouYFjLNJ)v$cxoc0qC#r$3-d+e6qdo~owb9Z-_1H2=d`X7`NYQJ5)xgX&abGz zuHCn%^4MfLjtGkkEiWfjzPZDH>??~}hkltKgZFSiJK+n_V+W$KyI-$sXwDq!uB>+4 zOfxec>oy4r-l_C<|8RpXq!7>Z@VAS02#tKNHVy)%_R*8T2$`}gmJUF0_Ym+LxT=YLou-!j#%svk7_ z5=-FWQC=pslz-TNjv1j6;~;Y#6eU9lTxxPd2K>H;i+_GadO?r!7e~{2tv+!|PELcB zjs$~i^Ubq4mOiz_^sgLCw|)r4Ia819mr2jJW2Q2*)(fu_iPu)D#!`|-yxh{|fdIFR zH1lYV`(}gJdA%(=_p8?^N1n>(E7pO|WduKTYeY2y1CwtW&0sB=Hp1`snrARd$>q!X^}yyCG$`)Z=GZ-`MNJ`Y!a835tFor_BgkS7w3Klt3( zc--FJ9`S*G;)c-BkxJODpo@o?m>BX9pSP8wUxeRkl-ZqEQBhf5X=R{U-v=Ez>QAXx z*UniR47(faE6V^BQYwVbjg6gOhnxZY;8P4(NSWIus6%;~M$2DE18a^gGy#=iu-NtW zH#nG`PB7T)VdrslXwV7~yAGZ8Z;!!W-aTj>-<_Q=>RVn>?XzggsjF5~- zj101^4dZ*h&=@iz&RoPjy>!RIG;;MpC)eWhXA-bH$Y=ykhN1m@6?3bo-Br3&DWc~6 z@2SV8<@p~SOu8KHm58JW|E4rwc6Y@Q+Y#Sn*J=4Z(Uq;+!tfum({%sS8>hk{5c|tt zO5EJsE5>k)4J-qB$t%=^*B#SJ-um!U9ByWT`)H|HOW==V-+?cQIiF^27jATU(& zsTvqFjL6~|2W&QFoj7U2_LyG@qG5;Hn@mrzaK_=Lr<2=P$blS3UdD+dG%=v}@PKm9 zq|29;Grx{f{}3|~S{B_B;kYB*5*BpRxali}2o59*ALWudd)VaLY-%!^uA;G-+~RZk!-_Z)P=h3pL|OM+IHQia;u+KnUn`eYrg ztvDcxymr>ODgX})0gl%}NEgs3cVL2U^Pr>(@7H62#=j5gXw33Pc!g+QbC}2m6>^T7 z`};&$PY>H$NemqU?I`|4do7w46m^L8*uUb$rmeDU106biCmDUO*QaNm3n%1t@Kb_ z>u5*KFH9Dc#vDV02X*5IAD=`@M|iPZhaL(&)f3X@T<iqSKBq#lw5u`ka zcrlqiByj9aL2>cpu}^YkVn+W zco?whOp5H@u|w@beK6&rCDXX&`NEg*P8&&(p@M}x@%4=WFyKJx>(*!fC^@ERH!E{U zoyE>iO$>@^rix<~c*tkO8o;bd@a~%29Ui@XFH=%>7G2tnbKz6oecw!_zFd`|lCDC&r zc`OO9RCRO3lD~VuE?tQ|{}|m+oeIa1!(;o+o4HU$2-^nL2F&qKpJE%>-SMGP*V@6= z`B;gC@W*&`jc-Tmd6W>glApruY@v=OKJ&s|;1s2DB8yHskxYXiLg`SIO?EAF8n`?y>(bs+ZQ#w z5lKN5loCNe5EPJ*E6T6f1f&#HQbHOjgYNF`?vj?2eCO8td*AQRkLO+< zJm&z1z1Ny+&N0Ur6E45J7VpPQ)330X;UGZSoTPb{R99r>hgmFVckE5=O+Ils%A@86H(%+PgnzjE;Jm@YEG1;;oscPcX}*x(xzR^1Z#)?OZqf86 zsqE*LzEg3jhm&__IOYzt$(fm%Yv6UzTyyAYO0GAo#W>jNR+Hho*WT+`y~ZDua$mzz zHZ+>3qv%S>-bwIwAPa{zDJ8L=^o&&W*sH;AKIG@(k??=|hl|oEn63C?Y59ADnVgt~ zwZV_Q#b3WRt!3CQab78XDTQc-oUaAm&OiQ`9VXDQv&s!2BLW&%X78iiMzE6u_Zl!x zty7K&AY~Pok$F4VUiHdwnRw?VIN%R@Tq9h#twHJ_UQ|-#x9~v+-%fO-?QpjlN z?p)99!y|ww@?-9-kG{0JT|N0H{EfY;vPU-}TsxYYzKd%6XgU`y-1eVi#hSZ3;IQqq zw|8em;i0A`J>vuf=-R6VA!m`8Rsq)7iWkhZ#ww~W^TzsRnR=Yod-31^q-kO{e$dfa zJ$Fk&SGp;`ermGQU0MZ<)c?kF>3JU}4@ZGvov8u}Qici21C?&$QM(^FXrC)2GW=cW zi2DRNDxvB45E+Rkhy{z(G&F9^`QS+8#Why^9R{oX<=2zj0&`gGH({aH>o7m(Y_AVqG*4nCEF38QAfP zYIse&lLtdith6h^m)fz+9t@WCj}|!v;R*J6|5z$LbV(@H2v~{Hh!GAq{N0-Wsa_}J z!wtxn3U7q_`CYf|xS)t0UhAA{?&W^K2vsz1Ig5C2n+}N{0VuXu-@CuUWsB0sht-aY zgv5uM!8+`%VFeTpEb93Lpi*rBuLuEF_o<^|MbYD*O{QQqj3B7{Nm^Ex&(p6Gpg-6@ z0mWk7pFDdFx?j|0=H|6OzO!kwRZd>3sV&LHT=uA-K5ONc`b0K}h71oGq&U3=)fX6F z>72fzcQo%4IGTX{*VC8h#I`E@CYR(P6Q(gU9Pg?&9IVIQkmT`&VG?{$}ITJpbh|cc0wFOHm6Z zgE=+0P0>OHS;8|=5}W<4-b0~}Sqa&JD0SKBFOhbWZ5+;qvqfl!Iz9DoU0qr2bMBk_ zv9r+O$`8M{Z>s?rxhs?)!umKR9!X@xx2c$pjv5wA{kY& zc>DNJXaZ@L3I^$gz~CYjL4_GgV9N*Bn@U$t56vVXlNH*i#5AW22M^jRv6II3E{}>k zR-RMmX?1X(J_FBd;W0Hgi{S`$OEC!_(nF#oxZ-!dDI$dn+19HzY$`4x9P|C^xenT# zn^ zZ#Wnkb-1Wh5ML@jVy5wloST!NL2=g4?GMp5sYC9VsBm$U;Xtf4pcXtR%=my9Bm2N| zMY*G({H4A`&j~HmY$!ordjIv&5`u8udi>DDP&QgH>fXn zJG7c_r9d5K;Re2zW4s3!zPY)1a&FFmhex)7;maDW>)?e(12=>`_LR}_dHPkI4(gzh%U1*HT5c4E4>=V7+4P}X``?Q9&h^5dwRA&ckCdN#;N8Hp-|_{= z`j5@4A)fhz?*-uj3z5kuPM3Uq>KOGn=1!KnuycHVbV1Hr{mP5tUDL`15i>s}W?7xq zH>=`an1uDwQ0v81Sm9AbKHJ#beSNc%3<67G(Jps{ulSU=6m`to=H{CLGd6bC^^@^P zVj-FoiMXvqeO?&#O`(OGn%WW5P&67)xZKWk7VCglz4?0~M)&wN!+vxt^%sqfsEL-|lobUy}kWr)$t>Em)2MbeMNHzu(Fs z_oD#8547h92y5-YJqHUf%~N-@SCZ^Ke$#!GnCoIjfmWEn^)GCwHU40pA&u_oZU%^% zE-LX(1MGw&xCXsYy}t<8KR^uq{Qn0OV^NSrx8eQXnm;*OwC?7q+bH!O{rEx+lSu$- zdkDb7R4i?*6ai=O$D?k$-SgTKFgI0NXGC})Vd zNQE%_>*y>>wFjC;sB^~Oi-KO4zmg3lUz=Zvr(fr@PG9KBs%fPL9peC|J*m2TWGT+1^NE^6%w>~&ERO;D0|!!w>P*bcUf@d!yT#mMUuwc|Hy|6sP!gZ6j@aBsPHg9jf z15On%J(*^CbpO-?5zIPUH6VcDi66d|*i-kSt^txWV}bVflIK49twKM(6PCb7?O^B} zqp$OSWkEhY$21-e&1B1Nwlj!3zdM#lJ16k2$PP(ljG#j-ksD4gd9a)37_KqHf9vzX zBya>d{v=*PX&k9ihhi`E80v*+I|)&>gRb=yaBi|rJroqghj|-5L*gKzCybNKsmQJ0zSs=Dzj#+2F z=i{s+uY10m?Gp&bu7B&Mr$EU8zu1RA%C^=5Jt90j-1qOq4sSe?=jP@I>Nsgp$%kN!^x%`!T4zK!E|H3#3bVY-S$- zmJ+tf-M0W)0t;oBpMVr(@&Uty=kf146_J|G@JvAnBd4U)&o`Hrj+9sx7=cI#kP!@a zTb5)L3ak6J760(3BE!32zaZF{zfAu27Jl7#NYenKVADp%r%AQE!#YSx(he(p}I;)L%B4{GzOLp-0DFa)}h_y zFAo~cGHcy2o&ygAc@AV2;28X6=ax0gv$>a9zwoOnWWBt$8VJOeLaRLDo4hU@Z$`hW z1?Zy8r7UJKU4rUk>C^yp)n^aPFWoGtLOG1XwNy#56>qEGQli|vD_yU2Lo?GkL?SQW zWLz%ix4CFaY>O;*+`1%V)=oLBINEfhmUeq=pIYa0`29b;k@rXPu8Dt9q`%k|1S21> z0`!E_`I62Q8i;V@!>Pt&^aw7x`AEJF2o@OEK}1>!!ysz!tJG{p{jiyn2^i)HPXETL zo*xlp`L7{Z9N}n2t__0*m0l~T2F%xA|I)q%uuH%&Yw%}7>$u%r&QVce%5o8zfc1nc zH64N{zV9aQ?*I8EdIcUspMqDU-{S%=leAlZo0n-M$10k*2+-RGCJ%s&UMqh!5D4Lk zSJk=14}e6?M*JP0fLKTB_L764Jkic;yLBy~N74W}63F^qSoCffNQuJi43K0{Mh9>$ zeV=f6vD?R=Ly0gDMeDwO|K0M9VU>=~6&z!K(xPNlAcqTW9V?Y?x8NNe?fX5AllbJU z<&po)!?4E597xweT=ynNR8Hkbr?yn!Tfc}Mi4$0^wba86;VP_9Rri9WQd3iPKKlGT zO8a4S`|z6g3+ATU%WE>XOgWYkFCD#H`5tjywv~;Ok{$^1w~uYk+&+1!K{W$pVYuCX zTUr1mnx2Mx0q2#2b&v!WgO@7MOujL4G)#`>!(WgK=KvbRSjQD4_wLM9f}h#*mAm1) z2aDk8#;smp0rob;_7#!80pOt%(K@YJ2iYeKp(Fu)-9AiRKqcRFAUXxpkNcqFgh|w2 zzI@3A%yIK1CWZ?2n4+VrW*SO)$RwAq2^e9DTqK=VcvI_X=cBKEsC)$y!n(ut3L=nR z->v8|pKJc8`r`$mjjb&@5;@T&9ISlomiwiTRsGT^f=EnJU=B)!#AWbUftdOuO;KI_ z${IU8J!Hpd10bXzJYrUvDWl4V`xfdZz z30ml$<&wDE#`uo1gTEq^5^kBKjszjx_#hb}1)?3!2TW^x?}-pyT{%CUYIzHkAr6!w z?4~9lWz`LU&fpt}D1ZbWVfLue92jqbaIQd*zV_o=wPFKX%@Yef_WFm(VND$Pw@j?I zmD9;*rbmvWg&NwQSYUzz1a5xaLJ6D%z@RqiGE9ZTa{btv6xdnVbFBVprsT!8S5^)j zD!Ox-6AEO&wjCHP6xB&x!r!fn) z`hV(BrOX@OWPG%_5L@95;U;X8w+^fvxT$J-M_DyN)bg)rx043&e&eB|M<8{4B@p^o zTG+g^v$cNZvgFh9tFYt@b^@!CB`%(`ScG-(N7Kpt8y7UJaL_)6hz}bz!KS2KPl2}J zRsMZn;b&M(>6sG4b;O$|)o-I14<~G8w3|2kOp{5m_$9y@o7H;o<(c-;7(>EU-@%c< zipa~vzF<9d{X>`rkYJn0708~FU!OcPmeW9A(05~R?O_q+=!i3r_@l=D1K%Yb>B z*Tix5o8DjkU+YBFE9qAjg&asq)jCO@u+GH7kfk4V4~ ze*AgBI#^h?pfwz{oWx8_GQ_IW^Ycm36$DsRLYEQ2+EB6GE#&#p{w5=dL4SWgI`|k$ zLU0rs=#mU1e+8BeGvMRLEij|-&m4tu-359b??G{f)|$YL2>@$H=%qD7@d0;Y=J!WB zXxsrX3+9bBESldD1X8KX9cqu5VyxJBiO>oUsyG-yu7G7*qn1EqzhRm*6uq?e&QQn(EjCc zK7@wMU(kXE?`~ZDvnvJCR5|}J3#hC&r)@*t`X|OY91%f*S_RD|fDjw`obl0ewYM<= z>XMP@Ks|s8&^i;TtP%_U{vDr6^u5QZ8CVyGVfziQeQtg# zAjgAv!07bV&!;(nC%H^jWW#tN*>517bs-C`y>MGYs2!1eZwNS?^40GFEhne+H$EU37s<#Bzu#wo@j!5=r~DfX z-mSA-8dS+mT!%RYd`dc2QLEB>5{4d@r_X7#b;6y`PY~@ zTu9}$HvoU?nU6B2{pfh6aq=6&B*)ZCzzC-;%GL|ktH#}$$ZeNdp%ZY?K?-u98*7y$ zp*9VQ`g#EQH@DoXt!;&BxT-mW7ix=*<6U7CKT4Du)3sNbYRc?ESDs`L)mv#2Nf=JB zwWA1tcHJjShbdT3v|C1`z#<^%m-O3Nxau!iGG||VLkGGE)h#`3&$9pWBS2$#Nz4g&z>Xc9g05Oxaudug{CUS5#XOEse zWQqZ(Z0dMNn;teT*IH1%BUwyb&iAH4_&pSXUBlotVxov1(@Nns0QUVSV^Gag399~W zB6t3G<#N|6M`3{rC|1R4UmbLHu{(PX(}3qG=z>UF9^y05`_xh)wG2xwR(Irxxj()n z#+@TRcgsXggY8?{{AjM(3w2#18^Wxt+CMzJZy>QF&v;o4IwO>P59fHq*4#xj=?#46 zF3E8J%ET7rSHVD&J<6g`4U6ys5!;<25`_n`JVIU}e$M3?=g4%Hmx8rM?RwLx9Vam$ z#}QBL@?%(F-bWBmMEbtiW4sKMcLZaHu;;E|`S=`csmqrQbb=>*E&yt?v$Lui0^fzp zU{DU)+=8y@yU@@x=)j6PcnP~+J;9HrLUh_8G~-bm2a`zXn)J&3UJlCe{u1dqU+8B< z$-8rxcEHW9_8kj^bZAo~;iXRR;6>d~BY@%2zLnsDvjl+(ncLC`x)8llB;`qt@W2-* zID9dXwHVjv2r7t}?9RRH0Ci1IC%bw%okZGejJEYUmTr-;VIpvE#pI5ldv?ua1s6pa zM@I&IN^c>aU5OSK7lii`j2nfbu(WB8_r;BCVetl-U&6m(P1vDbsDHt<@m=)f+!AB< z4pIT2UQ^u?*8-u8F3$j)@T1d!h#xQHcp?TMN@I7lhw`h6M;O+)Q?_$e#6JimLjWbc z9QPSIR%a(Ns{w?DEYGyy$DH_^QGNFM$!i{79lpt?006}9f2M}~j>0B--0@g#8m0D4 z@2qpOiP8Q*3Bnh~C-4A1NIw6BSn>V=9Rn{W=r?mAKWD@$_LshD9;Cp+#c$~y;;87X zmA-Fhu309goBBoVhWjcMqB;^mUHqoTP7JvPc_#Ts`s&a2))?yJ)6+N}Smmh@fH(~3 z^bg4%`x#c_{iicc8(m4r>Z0W+!KHUQ1RdHdSZ5DG5eFIsP(%1&F1!sQ4oX>r5}0_M z-}NM8i0h>Lw@id9{jV25i@vYc`VvX$@a}nH^q!J`eC6=+v!O<<;xoV?L=a%2S(V(P zY)_=C)z`2!%>R$>PH(vMmT|;xfNNPm+0l^?c@8S;2yRnSkle$X%C(xRW?cW8m1X|@ z{!HK31CKa9KE9U^M%A)NfgplC0NyZl-jR^I727SNech<2w=)ePdZ2$-t#B7cL0jnT z)3LC$>QA8aUTghHPzuPT=;HUV6^D6j9YgNd`HWh_iH8Rw+$;&DWT)|DVaSku$5*1&&~fK zzq@O)^{>2^rX2}Mw4}Nf5yXD|cx;v&aNQR(7n*4V_%%;$$D1<>#JU+5ww)4r2nW~& zZw#VRV0e#Ek3z7&rr(G zfEyyQKbnijm%y@c#ibYVJoME%eU;H#kN=-Uc+EG_@g)D%V7R6qNH=wLmy2kEr^k%% z)!KwcpO0+-S@-1BbKioLvpOJtF5U03ZYA7%14b4e$Ifa(T&oKWQZF5KAMo8X@7jxW zoPR0`{UU#kw7;O4YP8;kddHRMm!(2%2q+a$kr%pb@p3_=Q_JxAjktvuRFt6H2?iD6 zKFgESBD2EfhYz1Uy202LTVWnwB@Ea@;5L``85S`X%O%tAmr|1rg!~^+Vu`NaKfOW2 zRk}NK`^CstZRg7pf+Wadm{A@jAuuT8jSSEHC&QR_Ig?NZ-aV??BXzUnq-TIXJed77 zQ4GkIz$LHbpwlQV0MT>fJ79^TF5W;-c@FCN2l9P!>0AcQm%>(kx`QJlBVUlHBBNaXXDW(w|dT6}?w%JID{c1HlyEz|i`IdtRiqBmACSvGd zV7u(DRH0%5{qw!H=6qJd#-vj{=Xi@K3Y5sm^A8t3XyyK}D860!@=W&6=G-w7dY7u+ zb(2+LtfW~U(HDvg>ys6jz)x7IuvV^v=&R+rrUZFhP_JkR+Lu@nefQ!eqq=Q#Hp2tT zf`Wp9@I%&|!jqG)Eu>RW;#@!ZWMU&kE7l(YKp^ydHg-5pWp9P&Tzb1@5?sU+MB=It z{OPz`(sq@SQAED2r7zDiV4;%|nm~xBZ4p`B)|wh;N4`DnFcO%bd zS}%m{0rFzTvkT&r&CJ3#n|HQnO0B#!{lSDBm4Y z>zo0H4T3zizeM1hA7$k9xmo1#5NHbeLTU%C;a0{;@cP)dvt7u}`veOs-;!5ITbf+< zYu}ge-@9iAfx=Lq8t0Cmr)GYq;0gkCWi3g?BJ};$f>wD2a(#}g#zeTec1WCOdrD$24t;Na&UJl_@7tkW*A^Sh*df5hizB;WW?>Pe*VuepD!!ylsam#hBGk@u^eGeJ zpA)yL&d0rz8sGY7$wA3D9R+g83oVa$`W$Lpy0^Cd^<|r1obcT)ZQ7I1BDrYCYCzFY zdtY@aZYhoVj%N9110DlP-}ez?QYVB*|id>WMyT^ z$9AOjnQ)j7-+>Wy;E7(vR0aR8JDZT0s26seqftbR>S};3D>%h`LCK*{JAGowg`wi1 zoIU{ik|ix@8&{D=aFc}#u&7Z%yEBkYTTMPMuEQ&g#=$}9UokChTn}PmL=tH_22yDt zL$^A*&vUSAM=UEeJUl#^!0#}V_hk8-V*>uM#C6p^xyw;W6`bJ?q0aZ*Iz;@@Ur-M*Wg{p=YpL`u$UD6_u^6 z>1b+^Q$+E^XfZGMCur%Iy;fT4ZCKg5GvM%O?@oVr$2qvc3h!%_LS1pmLd>`kxC(rMuteA38V7D_OTCB6CCV3KcjZBSy)N$c4+74e~!j%JN@p zx;0Le29j*Ym2V9fsHxBAsj;rHq0n-v)0Ti6_5Z8WI9ztkgiepPa0&Zvc-z|8Z1kyU z1LqVcT5ts1d30bR$_v>i#ITbcJQR`21#OE4*lbfXt3FpZrGe*_n7QIy7dpBXo(pyJ zS}_;C*sFuB#S^(6mEuA!=ghhpJn0|Mc}~n-ylFvAqUXjNBEyef+HJipcoQ{ENDFR`2dz?&QELgi;PLg*#!Uy;fSETE&=F{{ShK zj;T}Ib*!@zV;u4H$T|EzHeUo3@{2%!g0Y5%`9Tcd^6dUUL1UIwMHkpmjxP&&QU58h zOF>rv7*$vd{+NK{#q|4ArcRG8S2Qvgn=W)*T9xx$NkKRg7%zoAzg3oIv@2aOXHI$4g(3Xk0aDjo?uI zu8)Vxs}gOe(V-V`NXvZ|==)M0>waK-{rdIz+}wwuMHiF?3@vXrprt`@pyJOOBhM!l1=n(@e1~@CLW%Yk?bP0ty zxHgziqOHHYwwT4J}UKaqPK^ zVBany7u*x`sa7W=sED7Z+`_n|zqKWl{|+_qzp57hJmc)B--|3h*$O^QC>0L?`GeXv zi-1iK%iRAX033z=YnZ2foPCAy4s@}%CQg{NY^g85s@^}P{Q2YQ_T`HHCq_C9yq{Iy zk6l7cJUAm;^*LJ}pJ~=m&d||@EClt)1a-hSutE-`;{V=s|6|eC$nbFgCD}FZ0_rn9 z;@0;V#{G)vU0nEA+NgNV4Ejd&Ig>Vb=Cz_rw6wHdg@_(fpWo$dbu*$w+`i8||E1*p z=4{lcw7ztx)N@l)JsxIVw)#)`#_}4g6u$rW@BGTJ+&gU89lFmI9BZOv+}*AUg--te zbZ%*c2g19#g|gYFU;Zo_d?C=O@mBBqBh-jVNMa6sa0F4Hw|MU?pq~JQV0sae@%Y&p zPC!Xl%Vu4VDnxkyjA@3KzQoA*R}SI8_fGPq0gr8Gn6 zo$B{8&PCDmd?UIrg*iIg&t+e42X~f#t;m#$$F=k_F!m`?%$47b8gwa+j_qU0zEWck zfnl{4NF7imb|i6~?$@*bX}(M_>k5}EvR3U2{(P5)&7t&@3*&O?_aI`cCq)Yw$S&A@9~R;#CV#V-psb(gtPvfIevUBX4Il2*Lp3tf=gr4f}G%q-PQ1LNMmvIYbU%uFS;_j9x`pa>n}3D%+A&@MaE|F#@^VD1$L zuHtw=cdw}RQa~FB+0TMOf#ax0&QLk{*j{KbmxVz|pEBUqk3Sk9Q66tUIIE#Jw@Ls_ z^YR(X^>pV}+^J`%30^PH|BUFyg7W1H5{^$0k8x(O23-GF;ay4QSJ4HZn zpuQE?(u(a(=p;<~*}SpySxi{;VxMDh(151}3#gDD$ckb>xiM=BpCAW4m ztJ`|Nm&*#BUulQ$LB{WTu)Fkgb9BW#IhGS1x^}CG08{()%J*0;7Oj$83;MUjyR^@D z@EebgJ6npEJxY2&{XTiehOhVbkvxkm#rqSf5y7 zJCx9Eqym-MPa}6|HKIeE--U&F0TB?c(OuY8jEvYoZG-4=zgHgA5%DQ0*g*SAzxVz= zurAOh4u~_LBS=vu5*}^1p>|W8DnM@m_@yZldnR`PmkZC^FV7Dgx;g-D3fAHiha|Z4 z^ndxl#q@Xo(r$2;1b3enf;B`}``OK?-N!tfhC&1gL|-JbxuWh^;EhkEZ975O`e%1@ z?##zvL0;v?b04Re21PTHMa^M>E*AJ&R!ClCi z0_aHquCnC1+t8Owizmcm$*<2hdURO4*fJHfdeK%Ieh~LsnOlMiH~N` zWxJH2VPTu&F6*eZwCOoyk?`Ss~92`i|1M=H@Oh_N0b1VXYj*WC$Rvf?iEa*TdD0l_*Rj4ZO@y(B7S7Z-O-U8@!u(OPT z(70~AsyfdL)g&PH*g(ki3kZl{I4@;9sU7+3c&WL+C+b0v7*A(>Yd)d77n3d$D9t( zSA*=IWPx-PxRe{=5=7A3kq_L1gA-L=Ihw7xq3WUzL6Ww5J7|@klI!fyjnOG(wJ zk9EU8BKsZ3b^U&0SCC7gbqN7JmC>c1=|LymFIgbN$jMUkg|r*RGx`K;&l)e?Ky^~+ z3y;cA6|LLQjw5LC7GDa0kr1e-fK@epfV*W<+7Im{-PWuEKYs=t0v&2XZJ6mg14UDz zYY_r4nIEO#8Le0g&_e#z=^cX-_Dbz9$kQjIpTKkCa#TFjwxE?o#apOU3Z;^Yo|Gz5 z*>>-DwJ~z43#1e|;oXTMUKu|Wgvz{@+|MuR|N1DnfSETf2=57XNs zfq;QEB*&;44j}Z~YfR?|3G)`UBIUHRR}w=+s$uo~Pv3cbu!9=+iz&Bsmq*!7qC-?< zk~!72x`&4;QM>`9Q`Wy{rRCy8t|GY(>spCtM^&`lun>Sb--yM)D@aMApgklhE`G*+ ze+tKoPZSC%05B*sdF|wz442!=Rn!>lUUnaDotZQ;bPsy7gR_!Z(jM1!>bjr@dZBBT z6F8tKstFNY=NeLYun*OaKfo2JB6ejgJTQifYy+w+9n0G_5Q#VcN#3B*$LH#{y`YGy zSm0Wsx|;t8x~@N1sZX``7(nE9PxQ6}Qt6-BquZVFPb!Rff)q5sKlJD|9dZ}??ihFF z%E#;sFJYhGAe|`=)%J$!6z8@gFRGVO z4n!f2n{M=d(ngRFA->CQ2ZOZy41^#=t?8>P8cLc3Vcl88FV^Z7+pH$qY=vpV)6g5O z8j$jbMa?00`4Zm+pp?N$HMw=Wpr)rsp2`;X8hWKpWQe@ZrZB@^Qlr}XaKHjIB$9?Z zsmXdg+_xv4IY|)|Gq8C^<63AfTOy(e*4zK{B6jxH>hDLrXkZamnKvidFZH0Gm%k>X zCc)s2p%&v^^uDjZe;UMj2vS>19;=e{^y@c3!L1jDz;?J+Um3=o{WqWMKf!JT$Uq>t zzhQoq@*LI}E-o(n;GY*wWl*;1R?8dl>ek|-zzIsxxJM-H4W)%zZ#YA?RvA80xHs++ zZAL~x;ola`ixT-zF&2ppI9$k$LX8LWto)jUU<6Qo5pc5*R4sTBUJK^TQ2kM^*nr0G zKCH%$$YpFEsx(WN8^VJFQc+rQ9tulXC2YSRanm+Ir4@0rk|^$Y0Y}546IE|{x*JC3 zK5A|0A-sH94G?t3PwrHdlu3$}PKAE{)St5zy@i zd!z9M)wD-Mbb;Iga2*gD$bDk_Sr87lmXJb9(cjb-$Rr3XaJ_ddm+YT|tOuN@%}a!7 z6p2(g#Ae4PNKW?`q(HjEIe3vt2vaAU?DkxZM?7z}xL0k_!JnIdwWJfu(ejo2gDtfj zI9TUvBY%(q6CeN-HB6TjaPg`9P)-Nd`AdfaE^Hk}QbW+dpF$(@rpt*D55~gM6rIQ( zs>`vJ6irc3G0cCwR44oAv}62p!;>cqrNxC~SKDDyyb|b&Q2|m$-mtfl%01unAAjy_ZoZd0 z++WVg;NHb1Aiowl&%yq1r(qF5+N`SZ@&$amzBn11jmauLhqai(u}}d@;_SW7dqhiS z3v+V}L)2gbX}>-m1TGb{&>cstHm4z|}`tNLd@kDhU~eT_Fux@wrDW_m%KjcBh}DU~yl`OSGJRf3O5->|0m-7{&gOENM{ zRMo$VEGl{2QN35~$L%3{yXioObbcPWhOKepuGQFDOp7gDVW3|#(t_!Qd9*PX%)}8W zE#LAwD=UvY$~dECNIDN&Q*z!8CIWd?#PK4NtwXZ9?6zC|SCNd1O2Y(>g9{<=l{2Ec zyK}1HXWa7@3bfp9@EcJ0!*Gw_cSgor!rcSh#3ST^c=bUKqjTY2?a-G0blFXzvG|Rb zBq6sxW;mOfXT6V(o>=6T3ZbGKT1#9!Zdg{%w$r?0K5#aEfcr`?)%LHRD@8?rnAq65 z4s82+O?m9Kt2Nu=eS&>gbFj0sN9p69^JoZf?Z zD&#z7%ooHH-41ut<(me3Z67HsBWXBVJEo@6R|&Ds zo`uvtrK^+e)-BoZ!j2oy`2+-}OV-OtF!^*2q-mcEIw){Q-Bl&Y;W)@oY)>uovCN3_ z^q|VBizewzzgMWv4@348E;CzYk%{BF%U8U$B%&ruecn58J3WK>b0Sfk`1aHzlLnuY zBiFJEZu^%k#+&QzmAk!no#Pdv2~<1*{%S^inFOV#!DYB9f*9kpZ(ILyp4-$h4bb<0PFTnPG*3toC6e9n}!Oik5GCvj9Tn0VKg}%kIWQ)SFCdW zXt_c@`pfTdK_iu^==ehAC+hFM7%v>}aFA?wZYo+5nI0dSm_L0=ig-3>+-y2&mRZ{U zJnHINM33)j;>IGXeYFYb)~EK!8ZOp zeZiA2l#Oc{dv@%}vd`}73|nw>u{%9_R$Eta;L+nN)-g=3{JB3ktfqHTnyRB?aUS6C z@$uZerm6Ju;H#ouNwY6EqsC6j6AGPI53}qmvaH+soki~7Z>vH;_8O)Cz*6wB=wj)z z#CE7Uc?k?7qFVv)VdPp^6B-+WUvtHy4q-q?1>5=8(duJq-Z4~tQFrMj1C2I|KzT(u z!u(O@LwI;iU0t$--JR=!Pg@!r-(@cJcfyowj0aLEdGyR-PQci~8jA+vbV#@_0#VQoD76SZH2bXwFee ze?fH*A$X!ZD0IXUrg}_c?>IPdrMOW)1($p=;Da`2?KPz|=I8OZA&$Hq5#|o0yz^!Y zFGH#O2R&l|H=gNn9x3fgjOe=YX4Foq_X_U7CCLXo8V=b-i3i}f^A-b80Pdz3Dd(AV zjO=((uFh!4tKf-(~=guH!y1KggfsOYT@=OLcCx$M%D zH!|PvjX6vt!1e$K8yiOkWt5Ue83Cx2HZus%9by)FOFVK0{ap}uy<#nkx{cmcuU`Ymj- zXDXI&S6xemG197wE2l7Z^z?$FqHtirdv4)L@Avd>P8XYD;_OeuI;0Br_bLeY9gJgV z(2+JS(yzGiR!B+}MgguqiidJ#)S-cZDrN@#xu!Q)mia?>h6$=V2e#n{!uqe(eo*bFgYm9O?tR3E7oAc5eo@sw z=QPoA5HJuTAx6{#dR=m(q$vs4YuLHCYS7{yK#J5@XMCu2>(HC99Cvq%`Wpr47j*hwpQa{_H_433h4=sil5^Asc zb*rizA0nRL^xpj13G_a&vE9n+@9jwS-};bGH&w0>BjnprzE8T`?-7{!m6yX(p|J3D zVEb#^ng^sr5Wi*`2G6bxIh;*S2A`+LI~oOMBLRywqPk-TAxdg$Y}o+?1|wRd92){A zZ^~0reNDXe;UL>L+G2Cv*gWt0LsT+m?So%hR@2FX2mBYP2?KQOs9>)3{&9#R1DMk; z=(BP81Qlh@T~+#VUs95emse$!hl4{5?BRrjg@ZJsx(o~rYrsPTT!Bn~n!}V7u$sDX z;lj0G9+E&xZhWp6tq6inwo(+@JPZ69FTmszVSVTddU$w@mN*b0ofRG)+bctl)@^;y zU(M*ZfOZbt-`DBs>Em{|dl$*c{Q$%>Dbs%R=uzMm-t!Pb?;02|$jQmUbPxn~Jpqjf z6*|IfzBQ6wtE7Cq%!U5;Z6Xm75vYa}hNHaTjgc&m0@Iv|iV9^p9?j64-UZHD^kYMp zPw(Hq3d}Uc#dq%=OeWVHTCXJ9d2xE>m&F+em$Ael3sMX+%RY0_HPbVuwrvZKpAJm7 zyG@2`#FtVoGGel{67Z^J=Pg@?9PC<4Z7e3RbA?qwV)ZCh2>YC^?a|9M&I8dEuh?t_ zaHm*2%_S-x8J_%=7mZ6r?Fnanb;AgW$apM3iuc$Ge0*ILx9x3xzUSgX((v4H;41Z+ za92+sUdWq3Pk1ir#QCx?)hxNN4Q}-f-|G4l3nigQIcyRVl9%fiL6uWpwHGbmsBOIc z5VEX`HPlN)wzhp0w(zq$PjzS-<>V7sN<*Zu4`qLG@~&e`!2om_*+{uJx6&h-SXpbf zMSY}2etrppgc~^S@lr&BS=>PcR4ltYlVXH#-^xzTWOv@}lNAC56$-dW|;w%Hl{4(WTh3I@3>|KI&~Z(nf6TlRFmfz5Wq~ssDm=``1#Y zecR!ltd6m9*CIE!_3~VIrm0EEn~xu{B(Vrz!K>V*+q`{CZl#F8e6NFIUNu=+)##Sz z7WLL__mkR_^7HeT13~`sg|tLn*{3_hL=W}*%WAA2C1}r|uV+h?g(e|7O?vnQ0~334 zt3#C6-s%45VW=XkxA&!4PDj-A!gXH~rdnxG8NI&dv&Q~Q!=5!iCPz@J2h30U@&;p( z+KGp!k3LkK%hgYUG@q5IOzrE*Zbr&H#@hhd=N1->c-6#V-5YHujQ#hEs2~klh^Gdeks**$aF=fZLgdrMZ(cKn`<&%7#rc zmQbuJMOp^7CrWqFY5u@0u-s!GhX4wnxW%WWvM@8_H7iRNq$!!Y9iLB16Vp#!-nX~w zVY)f~m|Xw5h6-(1XQ#pP8BrWqR{g1V@f&s@3GDmqj1}&uEnkP|O4?@p7A|X)N1<}t{*VJ%};Ls^A zjg{~s@oBji?00wT*-{_zM<%H0Wr;WlJ`at;C%-zWNMXB za&T}Uj}x^%v1z`AC^%jD3Ujc`g%=J?$mp@C_^c3^!wFdu%a<>|Broe580bLsBC@np`%Q+{`al47u+0&7>#}Eo_G4bW=+?FbtBkV<`rh z^aJl%*_2!jXjo{u|1m%(Q=oqzI&dLVEe{|5I7+FH{Cs>yF=rkPzQNb>Zc zY==NGZBzhE?wYQyi(qt83CA$QDwLWC0?t}pL+djkqQT(oMGxiqLht)Glhf0$8yg=q zm7|afbh>4dl}8uAfeMZoW1K1f|cR>lDq~CMPGK=9JxPX=-`}mQBM?KvKw-)LxP^rmPs)Zn_WSRe7uvy1$haM(E2(k6_|bQ4)9adOCA zP2Bqi|95>Y5M&hQCQPgkHVt1M5T4b29W7ypF``0d%3*$uJ(uj=GbCnTez@Gw;km?v zEveeETR0~>+Sty;OdsnU_sUk$O2}`Cgs|ICz6~=;Lt3h;jBd28!WSM(cuYET5JZ)f ztS!;$C-%@Wzs)1;zXKpsO{C&UfAH6ggR9BO2R_>%Br97U`mv518rq|tWdU*&_0=uS z>wm-V`&h8tJ9qc8Dr_n!;;Bj;*6U$26A(WHX+*xv7zAfNyI&vlweQ~bB#)>AgC%Gq z;SvzkLedNZAzzsDQhIW*jCiJ{F=b5+!$daVss+O(cRku{$Wj)?fC;VO;@HD?l?nMT zbc*TgUuTqyxE7lz4K}v_!(Zam%@>eLbZqU)j}8+;{d6a-qZZTsk?&*|{}iZvI||BM zvHRPo&-4aka#c@FR-5?O-k)-4R+^K)JiyvhqOBOBg&?+D!GCUy)F$28 zKXj@YEme<>mA&7qW!LJ_21fvVoIHmi$WY(8LTv~Ctb`O3!zfO-8wym@ zI<;1+mg1dkht~eamgfGf$3)duhqvC4_G$2B12wPxGXdeWbc}cp@^I}8;iO{2$syi3 zQbtuOTr>U&M|PgmXgt)F+2$h{3TOVDDF#c@qB z%YG~)V`3B13?VMo%(gRp9-@}8-n2*COVmAYJKRSTQ$^npOZy5a67}>aCwYo+?mfC0 zP^aWBPuQhq!v^BJdWQuRmAl_N)fT~l38fN*3~c+gQ9K$N8i+lJr@o0yyoE^^xbT^PgGPAGcsgmoFE1__>?-< z&*M~uis8Yk)bYcY;NgQ#A0*Ef+39`6XEo`0-Fs_y_vtz18*&kzeP31RQAF=JUmaGeLNj(wv-;f)Ls=3Clvo&y&I2+ZaPXxB=;Y94@mH7_qd%i7ZM(= zjoQI0d-*xsO&Dikngjz0Cw??FSfh|xbh3qmz*5+LX%{;BBiq*0gnDv5OVGK32T)e& zL)X-QV{d!ULTvU-Or+1ZoCpSKcIm;duhZY)3phIcQ7pS2;HPh()P@PmZ^bm-V0b$c zA2ptnm$J(RZijq6NdVYFC_is&YkL+8EB)%}@!mw8-4tZ@cDtwR9;a7e)jU?$4jxXG z2p~;JOT$IuR+f6fof9*jI{>WI*49qKu|+Vc)@p3SUE>^jWvhVosDsE z;VglTSHJO%TVGAB%PtgVpLK7+!7jmA+x3i&N$ga+$-7R?Q+~=XBhy^#hJoqEVeo&r zy6$)^`?r0g>=8<3hL9vwMs{THkiAmL-m+I2Wfc)J${tzew)d7$)-8J!*<|ncywvl) zpU->$@%*0OgZsYj@AbXD=XoB-aUSQCHgHF&#l^JaixGF~#Es-o7(1WCxeKXe3Yl76 zot)&;K=T}s9F`C0r!uHo zmrDr4y+rt3z{+sndvspo=jTVP6_8s6dCeqb=wNu*(9uBz+#O6fAVS=M;Xj%sdzEyq zG}qT-f${)xO90I+4#?m)R>m1(Vq(gi7pRbJnd@4LAyxwSuX#ZELJizAADcf4fWz>7=7*2j+0yl+mC||ayeviSvt?TjjcJ(~ zCW^nOcRGqn;&KWY%L54f&x*goMp2V>Q#e`_iF}}Hj&|Ok()8&Fs-#=}a-rC+x-6fQ zpPvD4^jcHdcW!=uN=bUL@tR0A*)W}2n~PhELjC}5wa1&~Vrn9M{c3_^MafaBy1Ib~ zhBh!j?c-rh4&V?H#t}fMqon%56=t{AhiSUKRHe+$4WBKobm_c?Ur(|}H^bsaDNgo9gN64+L=vE_t({um2M#M| z&Ype#tK%&*Gcyjfth}X72B0>E%gCmf7Yzx9FEViA=1uiHK5^a~D&ux`kUtVn2%h2M z>JLr>lQ*E)BrGfE=PhW(ylIoYwl1PBadUqgOn=ILBkrQU`_>H$lG>xl6s^j=4|<2r z6I6H>$q&}NSqc{%hLzQ_+h2@Z6ir8N2?BUiK$inGas9+XODDCJ|1#B3#Dd_!cSRpT zMNcE)!#VEDESb!lhZa%T0$!t4(L|_N1hqbV3BC9x(=wACbZT(xkp$&?{1cr0ZOZ-6 zMMZ&H$zuW@qU1o^KoQ#3Rz-?(H8FXvt)imErbtNC0!M`1=1`|}V^N0dt%qd-Ir-p? zj*Yv=#C{u+zDI)NwYmgJ z4WSx-@IV_s>4KQ9*|))wb%-_(`v2?}7O7QpMYSvPILZ3lWe{Z{IKwNatk!=t@>%7D zSTfn}mej;53gJ#6sN5zD4;~Wvdqc1H8?n9Td4_$^q_Qw@+?U3>SAyKr+~e$-nBtp5 zUbIK#ZckSjZ!Sn@?%zfd-AI+Vr;i~=mx4qG((5KK8yg#Nb-SlW$$9mvyy+!==zP;J zv%`Wce6ZY+4IxR%nWZP5k%_`Q7RmwJUNd+357z3KNU-pzhX#pnl}~L)${q1gp#2R2 zQErZbmMeWcO#X#wzuw|--sCD}$_1t_@xFZV;`@a(K_Ky9G>9b{O_*)U6WzZ0J_EPf zz0y=-r`O_xp4T11B|lAXb50inzUAtlZ|6zJ=l6dravq6XEabkakI$d>6mvlb%8JXo zs`0B{1O4yv9I89y%uyQ5EAuM5+I9u9e`@_dNYdMU-vmiWw3SEM_8UbRx3wNF_?4OY z<7LJ^U?H+wH8(J&5tR>zdsELts;;J~NkT(sNw~KwpBZuvI}bvB?7OFu_Z$R)9;GQ( zBiqPOCTNba_9$}fW+Yx5Cj*qfq55gJw9GE=wlEEirPD%&u8{JjkNp_CFWrGax!~&Q zS*Gc~4h(*}zVc%BrZ02KR`>}FgH~v$AlrNLCDZYlG_ilOte!i*4 zPKT5aA-$@qg;ybr9ML)%BPH`QLM6)_^C8UY4m^vmqN4*KzylX}t0E?*WOG<|sDv?2 zF#}U!IFuArS6}akrNq(x^=qH|Vhf2$Cykz0rKF1sAIiAx6=UQfl$1N`$;Tf{3ZAZv z5#O&Y+xsw#oXLHI_PUa#;Xj}C_N*$|UaB2-+mUCa@p zhh*Pk|GAnD99zWRA|`iZO15_;${t+#vs`g}cy55T_kP3&8EoCycM2tWdDq_kP*3c+ z*T46~&}+0moS-0Gd~z<(&9T9LW|1X9B>76Yip&<9;q;JcPTodm3}^s=PW zl$OYZ7YVBl$19F{R|X`Kd4I`X|x2!Ra*JMGuP`WaLY%+ut0#e$ki}-uOdW}fdp6yWC zw@boXSr$1zdwOn!IrnST)@}*r;*!4>J>iGdGS$`BQJGT|cKpZm{?f#er)A;aXoY$-YOPZN&j%)({~J{8PP=zB5&4o3|Y>j5nM+o+zVqxsUcebZ`uBGT_)jSVh)pi;E z=}xK*WiX{gp**(e{4!;@+S#lEW{JfY7WxZok$gQXgGOGVHfPPbcOY5yS#rVoo}SOD zIywO(RoLi?=g9=tVjc~G9FT^;aug!VHrD|Tr(psmwgD`$TTBD*{+4}fzh#bC4n8L5 zcwP5cG;4QcWTYvZ`tzqx(An`|F)}MrufmBP;#oKxjM;e@ghYU(2L;oZayka`^opR) z1oZ*al`Cz7r7}zUtELgmJ4LMkdA?h9d48crZaZ7L7E9cMeiZ=F3|8XFcwki+;Q44O zRJyRqs%WO|lhe2dz_wL`=6spJHlisQsfy;%(aVFI0&AOF&2MFON>9OU(^&>i9h>oT z4!7}v?*q4N1bURp@chclFXrTUw~9O>uNc;CY-yo}(9-nU8px)G^d=3S%ee(CvjnMw z$Rdw$eZ;{R|MMC1>P$G{!X|GOSIss{O1SUI>EnDA$_sl@M!ADWg6y{q{=Dc|;J0Ch zKn55l3C+6-YsjkvXel!{h0{M>Fk0H92jK_UNi?t}FV_RV+=Om=esr`y?S=%R{CwrY zq7sS-4-YrvdPkog-KT(@ARN@2H*Xk?-&^Fp{N0!d%8EBXDH|lJ#Yfq616Eg~RBG?o z*whGAcf5$2MbaU_egnJgET++x?bMXxn|gyTOj1HzKkRU$k*fG>Ll-$lUJ58H^1#2s&vLE8B%lm zt%FApiBF4f%Kbs8z-fuyR(HuW85UN{Z(jGIiRlE-q6$qKaWAv5m_RKIbHu1HXHWTp zv{G|A-v9P(XG;>0@tOd<2SRB)6oQ0?C{3uKW^+q|7_wpXfr6clO+ zRmk|;e(eRh++oILg!_n>s`6m|jVjPAp}k^^$Zd&5brA)A8Elqe{bB9bH`%sw?5}VycWN zcKHYu+;=FHTQQY2GEEx<0?DsJ;aVRU7rw#ylJLsc zAIV6yhO5+YMTqeUIlfP*t6TfEwcdmma^?cgdrZr}wXYm#BwRJlW_b9>sJkAH&ev}u z^Gix3Y{o>|8BSd{ycZy`DcK0vd<(LHyF`MJe7JaQe=gZu8Y~zuXHn)S#>Zq+ef%+` zJ-I6;aR+vNvS$SzFe=5kcJQbmpFel}!aj8kp_BoNk(y_+Frn@9W*joZ;sKAz z2T&urYgqb#GGphl4{;JH1D9x}wUKA$Qy&n$tj;fl&nA0NgYYF-^ne+}KX(D6fcqT? zp&ns!pgfGMi2edH$kh)y&E_>jnfkWpYBl zsK~=dVUro*x1N8+eqZ+K{tZH9bU}VtB^|Ngo^@-wu6A27L-JwpURG83lZO+RE*tHV ztkjemjPOI1Z2BxHWEgnBJ2r+`C04^6N$Xz}99O%amhkPmBNkdf<8(E%<>W2#+A6z7 zHS(i=&&^*BM=8iFx90p>-xSq?$lu)ET?dbT(>^8qx^xi!ILf%G5T&iBGi9epd$cl5 zhXAo!hVjpSGiNrDErIaC)TQYmQZN5;$H^gg8d}yIU#I`GLHUXB-dM(3lxmS>o~FEJ1`N18v zDK66+aoIZ^uIA=(UFP!ApoF`vvXfR|XE=b?LtP3~1aTj7v>rHptZR|pH17fhhrMoM z;KC2{KZB`$7K|S}R+7Mu)0DCgy9{W5d2cyoQ1RvFMx~L#BZVD=hg4`5>ZP|_tl4Bd zQ1R!J7|v5)7FEE*D;Ka(#s5GpzsrI%W=J3T-5Ay}l$iGU=UGbUfq_?WR)tG^98U)M z4*XcQ=FhtURF~@E>97lz$Byf;YM^dWODK`mHeFUxZ=HtIY^vIz_5<}?Pice@>IpGj z0C_;X#9c;DocV#G_4!^sPa7m~sMt}e4O7F!jh4`h&;I;z(64eOFSQxa(Vn7E266LTI|aEh zBgDxg?=g#&S-7VmnES#JP|u2wjo$Y>F0$D5jD3eWG`IiKR5T?kc&Cw1d^G+mK%O!d zx~%#Ji4%)`k~hwMs7)@_P8LO;IzzqdrRTbB1*nyKy#q37sD!uK>;+wwHh=F6(~RZj zFIWt52}@cJlO&@HjaY9s10Dyuo{-Aj?WRjNAyfz?X4w_k9!(#=C@4)&4;VW8JN~!F z7!gr2c_v46ho%?@05Z>WF*Xf7J=oRmf+4f`ce3rsNV=oj6`P%BpU?df5g8Fwydr=1 z6gM*GEJeAc`G?x{5y(IX2cL+k;7gxs;M&*J>@O_#yrf*NoebQ({odL7KK2iTpR5Z6 z5+{6vyfzGj@{Xtolc{87tce`qDNq%NQx`_3lWOf3NU zk3#9!zQ%#g36d{t-yKI)NFdp@dYTV6likd9%5^z63yie0sFh|XuOFIvS{A&PxLcv4 zqhkWC)?p>!h9MEPZ_{=AF5M`Hsp9I;hYHlkvU|K&ty@_(OB{%d_ry)LMv6dR7P%cL zVvy)mV+3o|KQ_zjX=`q3nz!YxhuK#F%uH7-?Xk^Ro*Z)mb;;XyMc$->Tq7Eq1!=Kc znJepk+Z80+{GNgFN!b~P4J+iEm`nLNdj<*hsU~@0)nh};MMdkkM!_h6zun!(Kd@T? zV>vx>$!t~nE*;=72$>~5%e8av+&R?yHOjJvV17S%iV!R)@?H;;B9sm2m|E5m^pauM zy^%9Dv3^7V)Wnqgg$n%Sv;9r|>3I4E?!Se7x@(vp46+^gkI8)3vvx|ZgK09;M-2@R z&|+UTG&M`J_^d%SU%Xst)U$!gFFzz$NWt%o&Nztl^E=#sk$NS7{5jMn7cL6O3a=z8 z4I`bzkgou)fcu{C00{<;=KD>&XfaT$LAO!w?J4L&%XR$3Q=h3Y9RH^Kh3ubsoiVxVhA z5R33FA{sIIPe!q%<`bxX2C?OzGa-}1NNVL%1M;+6TjO>=)4BJGO!oJwrR7~t*TH&;l@|mq9pu=64Pb4#=DU(0#YxbpQP!h#MrprsJgK&Z;Yl z1N7Q|3B2h79Dd+b60Cfe2+)z6W2cr~a$Y-d#?9Y*S1bD1pI-2YvM<#jx=NIn;Gah zyPbNPznEIN$`6f?!#)20=4K?FQR+vZYg~t-^%}a>%JG z0+37+oG*A^dMl@9)R2je`PEHg{a%{!hrCus?nMrn(!zCo4AnBC>66Vj@Huwu7G+7I zgt9t+ye#ZJ`7=B2y|gmXq!M%Lf4S=5&e80YQwZdJ@PG!wlTsW``0;q8HbjsV&Q<_g z*_J)bdimshP{0;s$t?u*)k-_=Q%bZzoHntv&7BVGdlrRQdoT+2**&CkmCr% zeLdSPwtWjw^{dK<*jdtVMQ(B`n!Q?g+Hv(nFL*qBJc?AWWfg*x(Qjwo1C$KuAsuXA zL5#b)YlR6wnPqQcuf_>FK=!CeO!VE&Rz36il<|h#+7P5Lu1DpWuZ>iL|1Upl+6qZd zKFQahe1Tlp^|5Yj7nayjL;f*}@NEH6*Ja-MifkJ3YYHDpo^d7MIen62y<3R`vQ=GsU`AC6* z0&wZg=Coh|V+R8-k=q2_qDViTfK;lT&7C_~*Y(Q~zEdH5L9nz25Oc7@tC&6b)4|o) zsYK1RF#Ps9(EYLs3OK06v1%Gfv!-|uK?e*agOPdv#IQ5n$z-TO5TvjwCj^6yqwF3i z%2kE#*D3>hOH9K@4DWkhrnlc`jDUauW`9zEq-isrFAAPHz{+xOW`kv)*g7GMkf?lG znnswe*$?9EtWj5w#`}YVSp$D^3jSBpmf`#`mKDJ4-vD+*$=I7$l;pGXlGs~AJ>*bs z9W@aB#Kw9^L-UXn#p!OC_ru6Eew}y$VIy9?e93wkI@fq2zuPNF$51ppDGp?wKxsa? z1)T=}q*`t5?FbNu__*x2_P}#~fIT*#a-el-^25PHp^SVE z(HHm*ce{P0L6!b^P>i8jJ?*04@7Y+zOUek90%mt7LNCdU*NArM80z=A1 zAdxJV9^L4D9H1D8e{X1hsIy$!(XzMY+Z`Rf)3*LGqC|0D`z9v!JeW?If%8=4A5%s8U~=X{`)O&Y0!_ZBNC~)aTtNi6uzKg7;gPk#?nOHMTs=HOkd~;KpFfQo z14$xzF^KRQl-!6^3;?g@g4&}TEu+0F>}p|xOXIb*T`s^IOM|C3$nv>O8czLFVS%ED zmRAdebpSqtc%TlX6tIb58SCLPR_pN?>b`y@2GxZ)+%iPr1KJ@4|9TL&G_5V$WoY>Okkc8FSe?OZyUJ-Jgt2f{!0DaK~qo8L6ckv_z4r;9+bQB#oN5@dVpC2)^m?}p zwZ{Lq2@3Q0|GJ=XOKryoe*A5OGOk0R>Yk2!flgBB>GCNQPn)p!0eE(TSlYdx>D1G^ zcPq+<<0isdeM^srYox2I*J24qmM*+O#Am~%C+C0PDNu}rsH;-Eo_wNX)F#yXcH{Zf(q1&RpwBO(3nJ-YmD|m5 zI-x+&vf-#IDY2jNmjKZ^wBBcg8F9iuXKB|bKHXY6>LE=DGn*Wx+*sE_y`FONw~-XS zGWU_5IS7HNdgQ$Rw&&r59TLC8e#R&D`tNx9_q$}2o5QGhoU$AX^U8V~h`VWH7~9>> z@(cn9zFWPJ4N)_C3O-$?_4oI0V&@28XAFXQh2~tLD9bP94SnGx!__-eA#H7K;=sv8 zEqp^b`J|*Iq=yE=Km@S&@_Qfi^IKq!B_0YW_^6MZpu5F^Bmj{}!T3u&ctD!>{3^b! zwY_GJL@mRALS#~)%-A%{4G$K)uC#LV^lrNN1LggA`A>PB7aMrTkTCJ+Ux%4m(Br?7 zDHIX=)Y8W2%Gtd(P6QnK)2wVW}&^^)e@ncr=ZKTTs zs5Rs(K>jWup_E*+@oQm$2$TuX$YBgBR^)WwnCrD)8sbF=Mo@OW$W>|%K(s~ z`<%(Pi+YzxbSYTpJn}`Cbea3+uXs_M2r8YO|FVMWS@3mw?j4Gv4b|7*|4?TK>B@(U z8NV(*U|`;BP%T)Q0_gCR6B?ryyll7MNTE=W)B^&Wfw^6=XKUj#v^=*LmOzzgRI6m# zUFNN4?O-!I=UkJ48L7XMfAdwJOHvIQKJlVLV>f|;&4JiBonfPgReHBPa;BbdD5XSp z<%iL~)D|KoKus+D@`Ls}yOmM(>9auyUQJ6G1am_X@dqIP3%ofTuyp}LB|V6h8>CR6!5sr0W8K0;4ief{q! zgaH85gy(@as%B>og-DhTkp+NjXqpqNt`;^nH4R{=Y?V(UKw8V%ITa^X?uGP;iDpvx zq=K9lYb@M1TW&T!X6=qO=MADuaTTNEQk%p`=Bn$DP*tz}My};ZZE*D%jiJ@@vcJ1l z23j_lfBIGBRQ)nW|K^c&Ud!I0fo~>mY;mPnMITu+w22~$NQ3|Zg%1EO`0A`8SIv(B z>zJ7D!GTsrW~jEH`wEZ7k^}8RoxEOBjc`8zzacdz2*><#b14no%t=w2+JAf)R=`-F zmr6_whl*+j>1o9k-h4)9jYgfZ1Xuu}Xn~^V7J6_4geFWb=*f@gq; zWu|Y6fro50B38G$p)~vouga#(+jDA2$mgW*D~Z?_h9&m3;LL|^#peLS6;$2A`v++2 z?qiYdUwW{2_lof7Dxm4e^8i;Drc8;SC6NwH0CTxCFov$nT-kA`OC@u2^Upz(JqZsO zh`!L-xafYSHn?u!2wep|g#q7Gb*-43TU`UIh;i7f7}Q&D-<=4ZTXlyd$igkpLjvTs<(40L0G7Jy`P+p5HDv4FEM$hDsAOfwCNe27T&EXOQQ91B~>5|Cu_jzJ&+f{39?Z z3zk=5)A5GiNW-9Ju}FEq{1_sU{@Zn{MsyHk6tb;?nR@)Xh{K$wIl3-f0WA<`vM8_d zJbRZK_coC1AUVh=ksfS38jG|ps9Db0zk1z#Ll;&*(wgy9`8yvn)T=)VxQI3ZR4~v2 zdq5#=G+N`RRiOJko&i94WVI(Q$(Uzv7(Q2X?$iX069_Ls?UP3(SPon^5NTtUUA&70 zlY;iwyF{RKvOo)PD#OSngtt{QY1i^bT49H7y4Wvcpg{};ezuReO>vE9302kY~Ge`40PYOYx|h`TFZLX2>tHDiHbID$jV#b zcep&6@L4_Lu~B#Xcf;u;K+T;>(NvA%Cf1(=35h~jdZqd9E0~)j+QJ;IiCzr}RhH-8 zn{Iy%6p$7x5M){R9y#STvxVYwu(S8>g@&xWmE8lvt1o4?k6Y2nd5*EBOX+q&&4E7* zFgAK-_QBku8$rN;8AU<_i2Yt#qWJVxpc^C$E$}1Oxa$ny<`Wy5{T_8w@}^pkv>k{h z+Jlt1Gq{XRT2>JxjW`H78P#r{(~{3ja4N;2!i%dL&0jy9GSt@zNfg86*=p$%JfuP$ z>c-d>2cOaYIWyY3pAZvDos*Lj7#22TI$Dw4a0cM#szPoFQyP;TNiWMETg^`q-E-+% zJfKhzx(X2*RBSSo?)>0|*z@TRAxc~-;K;|IH&aZ~9uCYA9TmRv@GU?H^`{-Pvs8VQ zMUJ&i3yX`1<=E$t4s#IGN`B$GI5qUF&mBPArWHC`T5$L}@~MG+8xjx)ckS80ce_mt zC;g;}|9R=0QlYn;2BkZR?W}g6c)la%oLixL9vKHcLt+E`_jOJZ9e1PTgy5GV z>ik=6pMfA^%~v|lmxh-G)=9b9>7-Xtb%9oJcLKlvjj(g_CFRbWE>bz>2Y`P&o)IN-Io-H6{Z7#)&u=H$E0zEE&cG3*e zy$W0Mp+Pl!O(0w{5~1JQ$oE5Y2(*phLZ4%Z`qM8DGp4SHF}9@+05!?(%>Ym*fDq7# zR@Ka?bLdS&2;M|n)`)W?5{-CH=r_`i@opM8<`A_kP$hLB>8HKT2kq8(Fzr`4IgvfC zAUz6Av6l^}U#z9RhL(Trklc{eGVkHE!U@)XCJ<^u{6_k<0j)&*RRF$)#E$XDaVdt1 zci%gZbS?tqNk~u!d@@S7@&|d;JK=_p-aeViMyF6=hhg-)_y`yY!h?x^jDdAi!gY1l za4AqHpfNP4qg&3NpAA=v>Kk8!Zl%D*iwuNyN;#S3@B`r*5m#F1QV)QX1PB|*Gd?}6 zuEjd^f;+{v;~;}19}`m}IDe&`xRCqUla%pkh&`kMUmHZS4ect^(EDnuhH53ZL8>O8 zx@k^WvH3#S045;V@&DV)P!{7wq@J9NwLK=nf)$x5Qy?~^FuwO>xFEX|WXj0SNXvMH zBr*sCPT`M_2F=K%r^F4AeIXc!tB0Gcbk3Y!N32@J`_gwrT?y5v&d<^#ZOzai39X5K z5O~q+r@*KKl3&et-s-)dJ$|L%hUWMEi|LPk#R{@$SZgGH*ax4vX^^MLg3xP|zIds$ zsEDe7v1+W5Ei6Ef>wH+ZHUlt;h*lq1V_tXf#>0juyw<}*TVkNXFUWJH2li6!z9D1y zOs)!WyPrQN-36A|W~AyF7-X0jAA3)AV_gi2i@PWxDT#n$SEA)j--2&4@>+_OvxZkk zB_mTx{*;Z}Ir-ciD19owojdPrwAzW&zGc?!^wqg6I8+WEoV7y=xhGkmeH+~_F%VL0xM)&ZLYfZlHFhG&r& zRBAn5q!3KoTOo(i4f;#xS6*~K!aV5MZ5bC@SogkJTTBIF=LJjq_aKm;_i}FDUbx%0 zm0tu{VN_ zFaT7>r{HQ%_#ZGkMP9nWCrLMeFPNz&+o1@vpN?mC+04*l&%BlE%DfMr^QcKih*0CB zkWYd~f^9jOpRKChaz$w6q@>{8xLwR)RU7alDz-fj6K<8-7r!p) z<->73Qyo$LI73{n6J#YM9+z&#}Zdxc(IDemJjRHi1koIhqC3 z7)1*2AA{hb0T3ai(Fq`7xdFewl{yoJT{sPd9=^V@27Z)mCk|gyPV=*&EzR@Lh@pwd z2q{ihGB_)78XuvbIRiIv!C1#jV=z3~^0jNFxmQW)s?w`?yb%tZ1gm(Lr2N@NUcTV0 z&uZY6;_!=b%zXg{(*QscX7IPZB}c0t^j?5chzgjTLhfE?T9htT*4*bdPfNLuTq*lU z9eZ}4CX1u*_LY+WSYe$ET|kCjo0`zV{kxwYg*0$2G@Dzpl_Ans+r+Rj8pPCs13DIQ`b&S?V&Ma_svjUQWm=_$L_ zIxN%iNM$guC<6c~btxe+adKrPLMr4uj(F;Jx~&krLrTku;==-uzAq=D#IY2Ibr&)- zdV%QE2|Ibqt`_vKV%01d;_wR6P=URTBG-h zMbvAf5RYGh=1NAecaqr@jV)3tjuIGs^KRR%${XNsAI<5aI+K|`2WfUJ3~YKr zC*sTx#GK&u(fxdFzgMr$*k5f1J<#HCDVfmIC2SOY#&6;A@$p(YKTY+^hBa^{0?$s_ z5uN930Lg#z5?KB{fN96E@IByIZ9b%miBX#8zkE4Oa~aug74#?RiY@@d-!C$GIZy@H zn#A%>-T0F}{6W-tQg2jrV&a^s*g5w6HSlD;+b7y@)My1jVQ^STW0yfCx7VH%*uvWr zw%&P$$GrCEQCeoEDcD|39l@2tur%y%wDP>d_cioGn>yaYm`6lNKuj-u0SA}#Y-Ww3 zl2Y^9bZfI?KsMuP5Ecly{kd#2Rz)BtCKgISI&fRJTlqTPJl#rv?%SQ(u)Oj6YC10p zYWD~MYHsbzOo~+O{;^s!kI{vTPv`X6Q>d$VPbxmw74L_(YDk=ieXA5XR zLl2q3gyOFC*S^8i$a)(0cMhH_1_5xttb$yN8AEMEN(0zDAmOHOvi;!=VnTcr>g?IG zC{?A&GW%Xu@=$==`ixu` zXy>t$e2(0Z3jqW3dLo6GOMHA(aE%RMEg8Dm1Busba{E(AaU%3>Sh>RMMkV>^3;qESY zaCnGV^QNVzXDi)t1IJVSj;@%5cZrE%S}~lBCDy~MFvW>_LkHPLz$6wH7m8b0Ts_)r zp#hIT+OvcK1CA6?k%)Os;;h%sWDv@iY|Mv&FaR5N&y*-E+kE`rLkvfmk2R|{Nza~T zRjd`py}Y;JtoUuzmszRL*K+FFNW9qTpKNB9Bzysn9nYPkntIY#D&VlH_hy94#w`f8 zz+Hn8=$C5V97pv(`CRR>jg10QDs{jc95{3{tu{6{PorqTDU70y?zY1z(ygtjH5h^L zU@}E2lKrA2WG{|zmqb0aXX71^(YvyCg=t~nT>Rb_1&jD0t4w=V6uV0%WOU25{_+X`| zX3qoHB&)34(h^3K-um(-3Kf0sTqJr-S6)3f)9T^Sx+r15PhrMj%C3%T=r!W@+_7tS z1A+uM7fE0Q{~a`le+siv>)MURaC)M9v6q%DIPa}Hl}72I6r@WcR+sckw#+7;;A}YF z>(@~;3^$4dULIpKQCe(dd^Kvao?*f76wm;dSn2Yj zSCz0=WuUf2>}ZY5{$7zqtzZ- z)86HYba0tj3x5Nfl(gt&=ANO3+=%!oeV4p}h45AG`h!W|0G)mO2`77Hua3>4>HCzdu2oK(LwWSsknZJi81dz2v~KZz6noW~?+!yyxR53b z>atMa6saTSR(Dp;hVG2(|4yBi#@(Wdsg$^07#=D2}UD;EV~qBKNhYc7<6R;Qbz(XLCcmd$TKQuNpL z(s=cZGG?8pVEcL_wAV+3UU(rR9eP*Igi$&904s=WQD%9GHdRy%TIsLsxl4Y>(3rLc zO^#cR*Y${XHz;rzwL}2U2#?_s#ARe@UdYKI1}5OJgIG)>cNz&i^*DHlS&gfk+a%;R59;txD#~&G zU}!w36}L9(lC6_H1ae*oxglU1|`Xx4^@XT$D4 z4NEH6E?99++4dcpZ2)GZn;Cre+RlvM!~M@@ih3H2feZ|ncvWgkj;`nM&J3v^B4-x$ zCTBS90Ts)3NPoD`!LOwIbXXYRe|fzHoheq17dY?Q?BRo3t)?2TUff>G3b8{@eE&b!}_yG83k3}e% zERO$wW#_EAn~t`a9PT?}#otu(^0|Q{;F^BjSfNuru~)loAZBrKiJZq4i)n^ae5+qE zVRx-Ic=t%?P0X7M>GW}5iR&Xo!Xx*=+9a=b8hY~W1rdLj`7_rzuNnV%KJf)|pPvrZ z+i<;({-0w;B-Ok6@9Q-lf?v|`F+z+&8RB~Uj7+2ZOx;|W0BYij0Ljeq$9?ZKY*+JQ z_jk$pSjW5BHXiB%6IQvET&Nw368CqNEOjrlDwfX6V5RP5mG18l+woPN)Od5xPKrx% zURt#Qis`*LV~gVoJ8KJ{2^XHrsUtW3V6oAdj-wwpXuKj{pP4T0(?~d46S#IQjM7_# z=h4-0h9mmqgB2m|qf&H>_bnuV_H}i-&Rk4D6hDS;r*ZH|n;>hj=MY`O@;V zs3SN@8rHeEBp26AT^}~d{(}~vSkMoL;7wgg43}iVc9K4r+h4Dw((A&MR58;@yWa8< zDM|UGdk}9_S}j&ih2w#h!I~q=&^It;7hilNN*b7FggG0}EUdWN(6;q?Oy5zMmixl6 zsC#m+>c1+HTrKLP`nZu2b4mjkfIrW`_okD{SW1Ia!b@YA?WTpAKD$U}L2A)TnW_EK z94_k0)oZtgoMMrcL|)*FL;@iog;X9e)Z3D2Scdj2P%m(j8mf_v&k*y#>1i4vYiSvC z|31ohnBVq7!ihFK{#=5xv-yn z4&9g0$d+M^`<6COYb7eY3O?>)tX!852|Jgr=5AJIe70T?=7JU0|GP@$Y20^SN$dv~ zsdNo69Z4_blVdKZQ&Ix3`=V=eo(gZiCL(5MF|JE2_UOYA&gw$+S;Yjw&cSr#20G)z z6SmVjejIN3GaF-)VtaO@IUu2SqDvS2q}u z+L4`wJ9WeFnU-(L%a-9|Ple6?*tY3C10N}kY3rIJoz4Cc%OM?X^?jWBF<+MB3Q#5J zu8NVvS4q=F-Gw4*ErXKxNV$*bG9SM z4(+=JniLQp8X+{1=!Y=TCH*7(3DLb(Nm!t@E}@kR`&5-_l`PICXHlUm7`H}#acA4< zeXRW`&0(o?Ic5`9mWyBDa1c4cnbMlm_3C2Q14({lZ1L9v-o#Y~c$2#}|6vt|)V%gB zIX$bZNm|iLD6+gmQQ*}xDF5XuaAZqV6D6k4i1et;eTl2 zhm1CPzHOat*sVlyw8q!_0jV|Y+qj$j8s5zSr;S7g} zPFBF$%EE_|C-?-_nwN)(Dkv0Gmll4|=NF#_-?`>!o&(9%?WgX-ZGLH;Z&XW)f@vfH zpFiJ(5-ZGW0a>c%lQXBMp_M?JHcRA+P?B26M}JnQL4rAV(Qv+&{1Y^1hz0~oC~-Bl z*F!ue$={@tT^hEb9_VqchRw-%Ss)hqKIG`zfA0*CV0Cgb!A`gF+O9qkQX+YJ5~Sg`xeF^u zN-to%sK*LV#Pt)GV)!uR^9I4DplOaVfc+8(og@c;{J=s{UDrJ&R8D&{$qhGCC0QE& z7OHG4)RzVcfx%(ZmYdzdhjYW>LoFkTE&1=g^vKiqcQWkiehdu4xJNa;le8Y$L3i)s z{`y-0gtadIbf`M6#}jb>iJz}uj*W^HuxI*<_Efbm-CA2;zq!SHNrYt#Ti%D?<(#ws zSzmUxJwQTn{&uFFyc;JUWKUqmp;J)e;&2-tZQ_A^7WAt%T3OsJHkwMp?6PYU{i=ar zzTW5XA3leySLSQNrPiyte#y4Hd9_V?OI)5fzHfx;%B_qwk~0#wWo_#uT8(r$D=zML z!G6vA3*cqc+lU{8RV?XA`9_5mw?up`ReF`F z8$L^xx6qK2%l-Rv#_IIpVM>d&KTqn0$+>yw^e6ie)!!>7l8FpqtHm7TP6FJi%}ZtX z`DPc|@ulxmm5ciXpiegY3(mz|`>M#D&tNyjugR^cPf&Ge#F&<2?cGY}TS+%zgDIDg zJ_$NE==p`hF98MI)%+K4aeio0(~@5&kU{cr2LX~!UTGvUh<7>~z6-%v9C1?`ChYmb zS83NQ>BCLO|87{`iZtWu;W@+rHcJ9+n}&U1gyuqj>tH|prFrWKVi=HMrO0iD&+T=| zZjQ?CwoJ8>w+`ng!ub-s&3j5FKU5D$%!BzGB&(GkIdf9jVyr!GV=E8LFWvZKX)P10CD{+& zi~4+p>F+^}xE>7cFw|rdh8MEOSZmM93Tt3&sv-~)VM+8h$C662<(@#?lPXr^o{~FT zLmp>ckjd$p%k_(NHTh=ULd3Ip3W9iGCD`fgo)@|15`aTG~ zH4UdrUId(G^#6P*kv-AVUQUAH;>gL`A8Rx`9Liv=qpS%lAjibGYnLwFL<*V+%YRP# z@FmnLol;eKmmW&a7SL28W8X$kvKZvfz-uV%HQ|Jn%jnivM~*q0l9I--m_bx{B#!TS z8Qx>}Z4Tc8AHFRE%KzQ9Bs&6FAvj7+OUqR8!~M3YN>7Q*g%~(P!Xg&%h+Qm)c+0P2 zovhr&;fb*x)UtMkMU&q9%y}Q0K-*qi?9{zm$L>!RPD0e=-zN#dm+X3&M+ooTqOa45 zjk_fK-PPzYLGfF6_L|F{{y%|#Ug$hd(<3bQr}JCqG@lr*cXmt^Kc86SV$mF+ zKGfNVawIQmed`6EUBgLS1Hr zrR}61;5a0lbvosq?_qn-p-W8NqKt0IeFPs=KZzTEFD=ETvxm6Q>LFn(lnxc9^Qpg3 z{WZr2X}WqgI`}e8@Y@U1J{EuP3j9+xv*59%m`iEe8t*sV;b*!_lX_n4PkpydHGB1_ zDKM^ZA_OXi8KC~VBoNNrP0tFlg^#DK$vMvB`W0?r4TosTKodEfnEzf6HN?hGRp{{@ z9|T&~R14(TRD-pgAKiIB(>do>uJI{%?*J@sepC|V|@xBeTgpC$j5BJoH zZ?)%%={PSuP>&P*BLR_$h8huEJ6{+<4DOtt1)=F+nSE-JZR^BFbwQW=@yVz6?UKyp z)pYj|uNJ;t_E5E4lt&(n6Z&XgW2!s36+qcr^h5-tEopL56Dup@PsGg4%@KI~OEd?G zp((&4V6YCZtN3w>11U8iHb9TrHhLJr`0BCk{`l>JPJq+MY0hVU_C8th8{w5KLlKBv z)A`<8o9qT&bxQL?e!_*4rEBS}dO1Y^j+qWFz`F0BoPC1nuO^Jaj9Y)C#%rGsfrA16 zN3bzi2f=Rmlt;OzMfq*Vnm9UQOcr7|0qZbVGxGNKMv}`RFTN@tGqYLN&Efd%@!#l$ zQ5DAp1g&CXYSbm~uQ)8YNVZXEbF_ObK=?)@yM`+p8@3C_Ist0E0Fvu zILmR)Wb~8oyb6vV7uSIGipqU2w?PG&8iJ%n+eIN(2v{c`K+YFqsHgycMY4>0>govI zp;Bt2se~?j5Fije4VX#OAs!iR9n#Ye`L*ZnueXtP*)Y}+aQSD?szpw6`JGm=BR}|Q zyYqrd@|6uDl&zHXOH6Kd_8maNkx9q^Pf>N#ojv0VM!F@gIbQ;=UgcQZ)H0gf|NUf5 zkV7kvVx{2d5T`DsIpn*{hq~HvH#`Qfoe!?y%R*SSJD@k^2BpY<*bL-@fCGiTeS2MV z0nT1{wY_TD#b&$N-^iylIZRnIzfcdG;vzM2CePn{u4ZOaou2F9v-QKw)#1z8oxQ!B zL`rZgg$qsy4{3zPl;*>^xw(^$4GGpLAa#gj73CYEVA2%iTJJxlV1tx=M^um+X>S8? zyFn^M6=ndzpC5R2QUihsS~`P@=cP={JXbEE^h&L;P-`PFbEX7=SLMgL0+YQrF2~PveOF}PxQgy5KRtd=O7ww;`!-&z8imFpi6ioXbCt%%Meqy`* zoQvBmYqH>Ykr~d)rB-J;Bvm;%T%8FT+CD-_`a4xMODRzRoVKp%m#I#dO9m2S{lokd zZ#&|m&QMc>^Kem7J>cm8Ii7+2;;SDHZVsLdbYTqMJ6Pb!_&iac6bLVrZ)Rqu0LwI( zvtfzf7_x??M1WLaE?58br00l|JOo++nZAR9)Dj!^a-?Es_W!i^)lpS--MgDm3=k26 z#z0V!?nXt07byi15CKsTkdl%H178D>5Rep*F6jIHHWoW=k_MH4;wuUSb;=FmyCOWw1L1^_-90% zX?;G&Mt=X7{L8)nAj24C6YzM41O1nm)lMP#crM;ohEUL62I3-w-3KmTj!i6}1AGhY z?*d%R`l*Ar{~Vv6rv^t1f@JULlAG)c3kxxin3&@c%Wg0P;PC<3_JSHV=jc;gupAj59S_m}d=hFf&U%ZSyd-ik1PS=$d-elDPuxpq{ zaj^s`DJfd;J}hF7fZR9e;>;jcjPJyQXANd>LJ0P>>A0qf$J%W_5Ji$#jWD5j98l|` zr6(}dsGy;t0nAC8XhbxWF>WAS@@4S`F9j(0r;{8BN@47I3J{1a-@;qM8ainw$PN|- zLli0mkj?JfKp>9qhrkoB*vThqj!IIEEiKn52f>D67?{!KvSp^ic$3h22pVtxYEdZ& zis15!+TOR;ZzHH%O^nxw$C0g^SX_{DF3{EM>kvmmuyWdK_w4Q-hBLgZ#l|?ZIMrPEO9HjYF66biXxt#m2_o zc>>Xb)Fq0D| zEB{%dTj;chimD2t!5`f4PFMx(gP#qdpA@EkOdAZHi>s?&WTX$Iy;5R`bXN#q{|G^H zO8@5O=Fi_vaj9CJg=R;PAj){6;i8Cu0F#u{K_I3t0=FF|Y~CztXXJb$^`rwNaiWsJ zV3Jlx15_BK8r!RPPBcbc2KN%B3yBOk?LB<>u&DK>D1@oNq6O}=-rm6@+G>n*SW{9h zoZN-`UO{@;^72KZZxzM7^NU2aF(+h~%MuB#$=a%_9bj9*E_;Cyhbymwsv}n@n@|7} zOwO~c5Bq_>B*m<^g7w^<0|WjPO-WcfodqVe;K_QRm!Q`72pDj%16+~R7*EN<`DeSM zP%Zh4vh_W1V}~j$A?;K|oGRf8d*xS+ln*m+G=X$sgsp?t7bFBHBqe)0^NCJUYg|OV z@O}5kPGo>WHv+s}jjxh9k)HGB8A}_V&?)k5q$5XK5pXq#bN&zn&Qot@lES=n%8e2< zIp9Ujnx0o3`k_4f5a;!yU^-zXm`2}_lOu;5Ctw|9-f3psqT|&^rW5v_rW8#c%;V|P z9Y8@ZeB78nAyGUhazk>JMOKiZ<8E-}a=p2Mz<3ZD}QgyBrE1>$D;0pAuO81YUuNe(LA!OZsl{$15N@F-fv zGO=IN6hUG!_q)_oN{HYb zCi_}aB6(K3gl@}e!(8ELhuUZS3dK#y722RJ^R!IGW2OSa<3?6_LL&C`y8TmaDUfUW zYBw!AP^2;3Bi*I^!27jY)b}O1_Yi4uy5iyXqo zr+chGo#JXDoc#TFq4iydKV+x&ND~+r-T{G<8%dFy?0m3OD>)MUBsJHYWJKN>J_q^Mc}ekel5<}Gw|ZfdnAKt-T$u-HTBbRkJtX(Oc^553S#)7FDq^VUVDeP;AQy z_}GueU0#B~@#yz?!1?~veC5z*DZ+lMd^wx2Luz&dGR=$+uUCF4jIt!v+q#)wY9#r+ zQrkff`*8YZX?-dG(Vq|daM`|?Z$V1_#`^JvrL^jd$@@LHM&Ry#hp@M=;f~chxyE6m z&>p+|K#P?R?HMwz-M0Cyy$vxR_5N5c1=rn={olg`m-NMh_yCE|mH37J-@}4XH`%`( zZup!#vj1X?&`nViHGuz|!H?uEJu%Vl_|!vbX|nM8^d7>$;ymCb$2CpOx)tr*oJc5| z1+pMJ&8fIhBCNmTWvqvq>nnT?I%{H?(vaPNj%`ctIE{me#O1K+jppj6OWoBT4YYaKpqKP$P` zKUR$?&;a{CRNZ@Llq`w^6WOyYVT{5BZe?-nrdLL-E0>p-8vXDXLj5x24saO*!w-5u0WXntpSrG$3$&3ib_q-@+RgZ zZjm*Lq`@eqtXwT|&(T5_#4gzh9sBr^PTHlQAOf}lz)%{|-7gO9+P%BUkpS+l4wp+2 z)HvvW@#-^pNZP#}00r1q-oAeA0Z2V}!@=I(;%d29BJgYCzgx(V%jU9#R#cYRJhQ|W z?recsz2t_3OlXuApkRwjz3%o>bDO8-`dvCo;u~B zGv9Ao!|-!u-a@VA8D6>Z@$pls8SvNuHx66tHok@{ z->%Xu_rAKX0R&{y-*(PFnHRC_qD9xMTm!+7zg0d6FC!fkcrQ0f?$PAk@7p6Qw656c z4tm8@y>b?Cb~23{S#D^z)*Ie}Kn=jnSl{!WD+BHzOB1@0Z@~<}nXSVWyfd@`W1rnf z^qYpOhFf35uTe3Hvh^v>o8Qtm1U2NXc%d{V_wGeX6xcyd;tfT3?bfX#^^$Tp@5{y* zw=~cM`uA4C*zt*WUv@>aTN+^ECO~gRF%aL1_gf~ z(+6P`_-`17;(S2kvqf%zIBsTU<^!n6?)PQwfLVnY6QD3C2~4-#XN3@I2bb~Vj8-{4Ep@n@7#eBIh2s6$B_yZ2+NY5rD*&($YoCtkJk;A;tk)X3CahgEH zz49c6Ft)7`iK=(qI^cBVYQRX;$_o=of2cp3Xx&}S_wggKcL&^*bMNZna2Y@veE`5P zXQEY4Eo?4zaR(-<$0Oe&2kH{!(z3CUgMtm>8kbUa!R=u4u?1oT#ERsQ%dGz+ z7~R63|1zg`YDm=wv{Nf?1wj3SwlleST6X2uZcTg=C+8VX0}}(!ZMmMprxK81s;gf| zNX)}DM;Efdu|epop}mnY5NwrR(b7T-lTb}nRsZ{P3xd1zPuCfhBa)tnU*i7mf%zJ4f)ZaF9y6OPVLE1Hp1CnQGW`+{nK=0|K-3I=`kTL@_!TCQP@PqW5 zU0Fs9v^SacPxl^K*w)jWN{Z#Hx_A*&KNs`?r)_4|5c=Sl`z!KRb}lX|>^lI^T7s7+ z*c!Y0+go4JpT3Qlakfssim{!r&dQRb^+Dcb-^>&wTjoy%r^+N;Ije9dB@^i0?WPWH zf9UB~Y(TRhc!qg&Wg8LO2fq#teNkSv=yj_DQ3;T(Z;Dhx_rfW<$Un*F187&oi7>SR zQ_j4dgmK)|kMU`82JW^jkE})0PdBs?Bu*k^JQ{c!@gA_8&cplzg~&-CHrn&?(tCEq zt3+`}2EVV#@c}~v)vN~@lm$*-aqOZgz|HKI6*^$?bF_BY&EO8Yzu#i*#IELfQcJx4 z%$fHyXFc7OjdsB$09y~p76oNHSLcc`k9Y5mE{60r?8A_s03Hx{vsG^ejIBn$&|oMG z$*vSi4qQPez6tWFHFCLvPq2y7f!TqOVAY=$vbNAD64urS&;Sx8K^yao8#uGXni8P;oVHGAe+-v0?U+u~dU8 zBlKcjbSOdvGh`>s0^nmns<;~_zxCIPvWKLpwpCOAa-iJxvUfsHQRMWYSCPG-0on;U z{ z8T7dR?3Q6#az2nlY8lahvDYwl?Yptwi=4MkhMAga6wYTjzSfrl4vuGD|* zXW`7b;tC-N=52xdaZ?S@wS2sDREWAjEwNkV9mm3Jf&J1LuMnU8Q=-u@Z-m5<5L_se8S|N%UbRHz6$`G~O+49nsYPhEW<$*S zg_5ZbA0Hp8w|bA^Xg=@U(i)fEa$Nn(Sh@NAc)zG8DPrS-?gTaL7K#+^uBpu(;w zd7LZt^xHET7y^#xh707ljPGl6D=Cg6xYFc8V|yXcls*HxYrdKrv~7v?sL}^8?sPPC zyA<~>7uK4Yn!W~j2by)L;CvKHjFy#^K@U46i(oLJpn)(=t?cTe2hYCRjR}0a-P`Ec z^m9&E&vi#kYWfI1aWiwgGEDFD57&GhQb@bB_Qz)UHW9t9IR4+ z4J|iR0O}wIlYiImK?2oSO;$O98R-VeCHa}4Z{Ve*k!;_An#9*N-x~*5`L#CO7&}VO zeRmMj6PijZ)0?9!{+g$@EjT{0V!w}FIl^e>L1M(WFJCHP#Y5q?9Rk@n{FV(&xu7!; zl>ogy3DShVCO2L-A1Gr6aV1d3`k+R{JeJ*`*!SdH)&ON5)~ZZQPmv)7OZ0h_5s|ch zERR^7nT|B#pRDxXdM32hs~?EXEO_EYUG!z{-#4ehjkeI?e;BI%S{BB|Hht;YE$h^W zi34vjdM;1u`)YgL5#e{gk0# zg($}#~=)oTlDqC9$@E(EuqyDVbjr@I&?K9HXCo_t_#eGmGV*Ebd- zx~vkipTH5@7F&B_u6dU6X+?~s`gB>}*{kLq69&|hgx9971>>gFBsaDH;J>})Oj#2{ z{Yjm@@^L7GVXz#toU-yhR8`H=((+1J^}8T&Pf#5_q=ypkLHQUM;6}Egw^!I7M?^#* zF$@UDAnqB;S4Rrq+A@=&)4eHX&mFdRCO@X0@YlUw` zfA!ruc<3-g@!1=pBs@M>2i%2qq(FyJ{v_wDi$MHL3T#uZoRJ$XY^hp{{5}_1$uTd> z_rcI5Wd?E@0O~x1zHW<9R?BKi0iG{^C_h7eV`HQK#uJk>)Xd^f?n2P5E9)+V6;D7E z$r~>Z%Z+x(63K?;l=Ptm&zQT;%jk2l3yneK@|6BqgnQ&!&MKh&4)Whran;b=IQ-tfE2b#eK<{;M*@oh7I1&o10h~ zKGh~gBKuoc>|9PDg&X(Q6}OXx(oE1C{X8@l;TI5C1R3dV=oeJY4J|Pf`xH>EJKOnw z-Pw>2D)(YlBml``6+k*8sW7%5f=l4Wfp!eRd}!^Qm1zmWe(~*mXSsT`Gu{`qtQ<#?D`7awfxtvuCAlzQ~5fe zb;H`eh#q?#CgH%1QgOI+@^1suzsU&>vG%KS4HR7v3Wz9`I$V-NiK&8R=#>y#Jvqtm zR_cFyrWwO>b8|6|xd#$Z@Z(+Hx&z(FY}7NUg+>JkM7(>ao|}M| z;x<2qghA}6loa!^W5@gr0s8q1uS1PACJbi%UqBSXyHCzX^Ws2js76sUu!=T!$_mhz zzbNYmBU1f2sYQE?0ra!cO9lIP75;AUR55t@-H&N578!@?g2pec?-vKtVi+FE9F{MP zU0cnx9o)1Ve4}5XSxzm^_{z?~`BkVm6>Wm@g^P}2>35Be;>v5R#%pL#ah*2#<rsEIyIP*=dEuz4`c~r*S7?=S-9*PNX_vs-MBNLEirY^)lwB2D+Wq1Nb^sAF@BZnCe0@Or zF-`U8J=4Fa>a@dPdLUDdgxsN_rkg>bFQRB0hHm^i2Z=<&sN^rC*j@$bH`iaf7n7Hl z$FOio#{&Q`q4)1D$|rG8cf>1&c_9_(o4eo8JR<(m4)a(O0Z&710|3?3ZaZ@|AkAYy|oXw}|W3N0}G zk&WXrxT*tn17?ncbr~u6rx$eEJHo-h^(!?G^T}_}Xynh{BRd$#kRGWtG-XzepZ#FV zb`vkml~#WGnyS4$;!ul{%~rm?(j`?3z6z;+uz#i1Ff%hB3#et=fWGHY5Qdvue4{;I zcRSuMHp?ay7T_&*pG&mi>7T8OdOHC>9XAbT@Uq1APf@uzW?D!j$lIW#P(Yv{BzX7y zqo$#!_O%{Jhw_3hw5@0Uf~7te2{dbn^O*K~X( zazxS5{M7wCB2K1`mzBNK7D>zfHA#}n(y_VZi{Hb9&$V!#XsmcdlT2fAs7>}JyK1Yx z*YOKJZpF5WL=XyOtoB&bHb+6D#z7R@!7BdLn{(H*la^Y6tRPS#S*TlbCT_ay`K+tA zYYm!Q=m`B?Y7J^>AL_4XOfJ*AwrXU&yN(uy5(nQZnj4Q-& z4?olM9QaAq5p@w-fchE4O^~rhHMzNz@C;6GZuJyzbQXQB^cj(vV9yI9?=dkVr8d1#0`e(>C!xe#rCSmLES9+KFaPJQs8O>Y?C!2Q<#M9&hrm zAXnF3iV2FF-kZAjYx2HpMHb2e{(-6X6^n!~F0?hGnPD8onM-T(L823pHgh)Vs^P2C z6Joh6CoyOq!o150!;GK1Zq`TKnl;n}KU{V!N#CMDP=Z)KpBBlavX=5=QY{m&@S4nCN*A zGE7vl*Uj!h&lqia8Ll{&#lT#5t!&hXT6uF_%sZFm7`%;Fcl$!(z1V_`!c|9R-rKpQ zT_%Cw)RAYKN})5s5u!jYy8Y8Bc{nXPJRYH!svQm8(=J=BQBF{ ztxmft=<7CbU|90?TW!K&CE%~GC1{NoYdjYY;<5qB5c4T6kHzl6i4wOP5uvRR*-#)GyU zBfb&|e2d?bX=w*NviqPcfSdDzx%>PM{qXg#sX6r-jWaJ+cZP?sO27k;hDS{`vLeE? zlK-W)?zmY9ey%3Mrq@4sKEnCuv=UBxLNIh_XGJS`3vqUCt__aG{sUB0#tRV!0>fCt z18!lk7t6 z_nyo7IupJ!;|uaxD}zn5USbN@DpyB#4k);ZJhXKlD!FsLZ*>G$BD8E4(GnuqkiMKC zgs=PidBDe9(b*n(k~$Z(X0UJuMO9Mxj$yLrh%dlT2ib^s_5b)%_(vS8=xa|;`u zg#}jI2uOA^{uacQTNW|QZlkwB%@mTkg2o~I?e@&SGQ(I}+ErxUQ(TspRV=|;JQgG< zEExVvJhr1?GCu5uueH^^Qxv#)6&;rR?p zxwKBcd5RA+oI0cs2H(R(|E_~W1C-^R!Zk!lWG7|tgsrdt%)QL2QbOccGdzA|&K+tL2b3AXr<#;*)vh-Ax(OP{L&61S#6q$U zR^N>>m$eXjBXGU6^6qPu{i+~y9C~^r%d{<-t+t{rMETkV{|kn6;)heQc{sS zp~SHZ`VTILj8IWhvekyHFfcKEE#LYXtOZ3c3bZW9$jH{9h_v)vsV~Uj((585L?$8~ z`W1|)nPmj-Lu)3-v~u@eyvqA>__jewP3>Da(KH)BK0ak#Y(M*zg1hYDr$wrLR3{ad zm!Hqs#n`Yq&t#sxXHpxsfs5$gLZ-4J2Tj>4*EDZA-t`}STOXD}VzC?w>h~hSTEDv- z62aU`kPmFPWjtRdksHwr*K@{2R4&2%A>E zg|0MI60Vv@Pgryb+3uEB9SUl81#WSRwOY8NleK=ka_`jo^JNGL3%<9RY`))@#|0CP zPT=wMGnq{T|Izj73>E*)K11KJde@ESY{G1b|1=m!50=ig7o zm`s!IQ0XNoD9o-uA7#-zR-H4T2Mqpv(Xtm<)ymu`a%GQDHGn8O5XJK z6%NI+){8hfXGiBd{50$-5|VLV8*_o~W!Y9kpKh;E!Yzk)8MUFE;z}P3QQ4yVu^*MJ zgoTif{@#3^>Gm{5=!AQi=a%GLFQa7%pF$Qs#MqyTntGyeBJ$g#pdeG1qVZ5Obj#ur z5@=#py*x4jNkVlnH||1PT!m_Gjjs0g>r_sS_xr;e;uKrj+TMWRBqu+AVz1undH+#B z*{2AOlD`%zwMrgZclY!xE&94k);7Hp^%Gf{Wwg2F;IIOZFnHukWTgN{Er_Y3)-<6QjmLG z2y5S;4LG(2bcOX(ZI?6py58a`_NiZJP01(=jgW9)SAY({OX=1;Vp)NA{cl=*+|g!p zS+}+SmhN0;AYi!~$>Q1Gr3OX+lErF94$1kSCl#y1MZ_AfS96N5> zDCM!x@^EAkQuOL!s9bP!#V`ALV5qIeX$N6f_xu)oRslLr`@b(N6a%YPV7=($U5fx!y0z3(= zkM$Yul7&xvJvEndY3cEj9Q@+n=2xeAv~zyE?_kk&`e|AI^Nj-ZU;{fQMzmvtbH(#4 z*}Wsrudw^;I?v{IwRQy0_T-IuF%uUe>C9FJ{u z4@AD2T(J+AX15s>_Q%O)2VWJ^byVR*pU==>t zfR&E@#?xPNyDDTNxb#=EB1fsc znVFgY2}Lm$Rw{~TUh(y>J~VYbt>MXEYl$KV);>4>u7ne_hLcy+p$#TH&Z2lW@BWY3 z`i^qKGFxq2&6DD$Kl7m6a4+t;%KPZ(V*}2MRhu(o!2xV&VUB6u^c#pXv02 zf)*TNi#9kXC#ULJMmoALQ$l7BrqlA{v$M0?^U=tMU)n~1riQ;9Ex}q%!1=1{uIRwb z7fS7OZj_pwn)>S1-|sTpblFDD+1bwh!5zmsHuNfliLWK8|E%J1;|u1|^5>CSkd?+y zs!Rk#ZQjYV=m~<6Tx(5`)|l3b3as|>f?X$!ZLQaJWh}#@R0z#?NYgi!5`>Ewq|_AQ z=jXpVeGcPwopGF2-N)jT&|c}0U7fC5oMqe?x!LthS}-Y&5uA@uP?`B?2|uuPH#3_K zi92|Cg`*aVv{!Q-0j4ZGYIBrhjj&Q41vK24ii9{?n`nzZ@FrHE@KT1&rt^%9)zPhm zUK<7flZrv3WJmNQ<4&GZvESk-%4otPYn~8%E7Z!lRc_<5%(J~bCA?6AY;u0SQEH_> zEHc+DK`_Al*ZMq8E6QZNtxH=E-k<+br;WxvgBHcU-4PrlC0(K8OfNj<=8s374zNy=m;%)r9Z05#NGzITSe4AQ2a z$U(~*8*IJt=m3T($6QT%pO%&ekQ$Eo=UTbY;gERLvHfQt5jxp_3SRx6&ShAa)Y|5R zws+n%K$cf(oyS+(NqAe@G&i*n(n|PLo=OsOKqhcU$l6r>_TUUBB*GiR|A^Q{!TVUK zA?Z$@%mf(b!NApEA?(^!P0ipuB8JBj6s9HJt6@MZk`4@FAz58-w6sJ!sPB;&h32hM zCDmnZvcX4$Cl^6ec0X-uQjf{W?-I|J^en0Fu70pQiM43C-o4&E-l5|JV~y>h8N@_S zf-@oZ@Sf;1FKXk3*-D~6bAeR?j;kHiNUc6y@$H4Zk zxZ#7l@D1bV<>%$%72)C+ROb^F=Mxqe;^XAy73bxReV>&3zx;u@x`eYq%itZ{HG5dJU|x}`6T<`0ZQP52amQ>!`suy=mxu6q2N!vT zi_U$U-2~AOANK5>ikAzJ$e-#odz3`YCx0bppF(N(cqv@-s@8i58QA!ULqdDUySq$?{iaAiPMs#ou zI^|3LX+l(=Z4lR$oC4Z*rr}7=Q8oYBPF5}@Ge0f5#lOL3@vA8=b7dk6s@!ZAd%1i& zwtNY>*!L9EWOITgKUIAO*9{$iF9{GX(6ntJcaV7u6W(K;KNj=-q@B-1M^!E}Y7o`Q zukc|>4!md~*XWS!SbOu_h(Y5IFi#S{DlO4x_hp3-c8v0sk^L1v5YSGbZwXK_ZaFch0=*wJ+|u4999{KJIjH8pAGH6x>U)yEcDI z1#lgCHJyc`4E{^7`JqhIbLA;gh0J#t)dr<2sHG~Hc6;X1Z41MYpZSE?=AZ?Jq_vp- z%gu+7992~8a>`DJ&$@-=>J#z{dJXTrac`-XevgR%-W`*^2?_bX$Sf5ki zQ2^TxMR~Qp!2;wemh_}e3DN<7LsSlNRJR8sS(18d8Z85-$!fD4j5^`JO$~h(wCeP> zE7*D1T*bsN1TK*~d`y4;OVH7#2^wE7$=7UxaKaRW-*6uxO9=B5+7f-bn z79&9O9pChOf%T;4(=VnqcLK0P60SLaSW>0PgNAY3+X*4Q&b`n{iL!y0P^&V1Je>PO zTOC_@yB8#Cu~3)5p=bzX8df>MNTPUHyfgDqS74|T@U$?3J#RF)bXa*E+ud*BY<^)U zZUFCXHR;JHx8*0Km4on&s{3tiDGu$o*%cTqfe-X3YfzFlNaJUclz!xGFdj2x&A$aP zEZGIi0H=LfcMRCvT0+Z_Eh~nS^uQw_EsbAycP*8n3|UP)7i+Qd()d`n~8SLjSPd{G@9U zR}4uvpDC=U#QYoLzv#=f#^;B{4Z8kvI1(shyl`kG#;)u(mC0ow%pH}~CZsbC_PpOm zCf8%mIcW-@S`m=AZ3MxD2JPZ@2U7r?K+!3pt%(Hm`s#;P15R%JtP18jKjRv!eu0;K<|p?aNSiE}Ol$>89}{E9FNCQ! zMvHR$1KiXWh1u+-egANiUid z@A17p_ym|@-YA$cez%e{5~`dlS;8U3UATvx-lrSey8OY=!tF3IWQTs^oN{#uEfD*l ztc2!XV|~Dxg4tNG=YqB(OFYk1f!xC?LNt}3$|qPB(M1`4!B!I|I&v^Kes=~IzGq1K1&(o2E98r3zH~y<XePsBfGE2U_C(s01(6?-#I%y&yeq1$39OzVgM;KzkV~@{kKxp-kw|9ft3_SNEg+5wUz7EE2s9kai7SpWpToQP9Gui zmE03HKfiRoMx0WCxp!~flm3CiF^E4rcQ7^CExDj63rW%G!_n)@1m&W*q4_n=AC52E zJeFYeJtySS9zsIycrQ1Mb+6a_$%?FIiryvKT1fR8q4|$<7bldz{LbtYG?%xl(5O{E zaaO}}8@#j6txAbFChXVny?6`ddVxAdRiIIRvj#YuCw1`OrsXU?>@YC_yuj(X-h6Fp zeq-W)UC3VLb`@Rxj-%5`>1)%JhLq69dkJ6$(yT$qRetd?`b<_LOTL&{A7{zXWRU$$ zO>LGX8E0O=rg6wjGiqR}P;b+{82w53XGHABZ71H%NmH@YFp7BMgw!i1dA+`90O(eQ z;IL;My*CAl70A32auVk{s z=_5G?%Y{!CO`f^myv0kTj1)X#Dob0|FTU3}UQ9(h={lLTy>SftmKNEs)G4mQJht&@ zILSDBd)cU{9MKC%Zgni{jUu~{;LMV%rM)xvai?X?WLr+FnQj%K-1nf(!&JVdrl9S# zoBi8J7?%yc7N>zkSxD#EE3vOc9j~@sSk&%G1}O3i-%W_FUIUxzttj_RH5>m4{qebr zqz7}!^Jo;*K#j*>u6zF3L*(E$zu!w%R4T4HxfhvjzcIF18lI^>a)Pq1vo~6=!)J?@ z@<&|`1Jv=ObpXm=o4BA-jAHkNnlC0)f^Cm)`XueK2{vY^HP&$o2VE**wphGH;CF@J z^8*42_PsU&pLUd=$A==qAI(u!<>h@*LWuf{p;TByU**Cey&o)_G!?uYkHAwy#jsL$ z$t2SJ_@0=G;{~Pq@&e;+!afOizQh?c zc5L;s2%ZV$#PXF!*!P2A4aGgcNtyIdc~Kn5VwLuoi(7qTpZV#dAVl-D98T%h%|gY7 ze@u8=Xhi7A^5QK00d$|~^BLZ~AeD-0_MI;!+kDoON`9L{VkBgQ{wg}in$pWn%>-L& zx_TqGF_F#4+Um@67#li-!u!MF$CG>9^Z==wstCO68yr>QLt|dk-J`MSWgIx2Q68(9 z7r!at6nOcz+E&G!7p=<8s&jNX=GnbxEIoAzjo8VTE59i(S#j`%C2;Y~Sp`o>buf(d z)NLsZz+$)g0$li$Y|1L?f(7%CQ3Lg;`mOGBIjxsqGQX9RQ{?-oiOL?$r;W>1oKd)bO=qYg7520u-KpsHhZSY3R@bgaW{aMO4d6~9dHHJ`c)>5 zLCzYtKH-9(UO`k?DdMwmCq2Ljxd-@vHZ-kspC%crBs!-ZwXw6-nBMki=3rYW^f*qu z+>IS6+QY7$OX8*=mu^5fd2M@e_&%Pd{&I@fVHG8+VmD$aH#eY_7RXa9-r;P0Ot*bA z<9g%L7^lbf8fl+WP@7Q7(6jo!@q-sdGUp)CaK9|zrKzhX*CWF05cnHJ=hkQE-yeYp z;c%=%s+6y$v-{XDeAw?JL2ZRiPq?QSR28;P$D*P}u(#wV4NS&xgyJ^*PhUwvW-MAJ z|1pRBgNOFGN?#+|Uf%afFc9>0fw~{W=a@Way9-G_7WG;1gU7-Wl&Na{ldcQ~m6($L zB(i?$Fagvb*o3j-@3;`demC4gZU56aGo+7@rsvgy_mAfvgoY}6=vy= ziFXJllRrc7-__F2xx2T4Qer6!94FFd<7HPGrSHKC-xbhL$5;f84C3UpY^WW5IeVtm zp^t?O5JVu{LrHS6d&)9E=AYIzAEW?L62gqqXku4aNo9!(l#Q@=yB)U$^2q$`iFxiJ zh=eBH$_15tBli9-;Q_qYo)2g>Q2>eJ<&{-S*uPY=0ZRzwEB4%W9ib;GA&_L{KWF?w z?4P1w(YuH(`1$3T{`ydjJ*Yz?N|v+ticYxUarL(Z*}}^p$26G`mOUYUVhYmGGbC7= z$dygs(|fE@^?K^v_RR{c)24Dl6=IY*LXnUsW6%gIiUG$jcjBMK7x9nm{|XUgMJsCM ziXGrE{e1VMEQ|b%sBV=ypt;mUbGS6kA5o8)sqA1Grd^wKa@mpBB%3%!EQ40|i#`3fTaOy8h|hlxZr_d}^2PmmbjuGc5oM^aI}^5| z_qY4vy^0*GDmI}r!m6;MPeRr$MK;Z|CwSRq$Dt!>#1x()(wQQFgnFg^wdrhw_{!_< z9Y}wMH`U5l8<}b?5g=JIlTy2|B6ap25O%q<@j$T-4GYqEn^%|XrHJu4#H{56Q zPozaB({Xf$V|jLn#^@teOwJ1#ba}lKP!_wRd)RI9I%nV|uZMBkirdqPPxldI5hrgUIY z9W6TMHSx`vI6jfnPP8!h5gSh+rpoW<_mmP+WGJR}$*8GPu6qE-1+i#~iO6uC^wQry$?OjmavM*M*X-))EzKP_wfqL}sgr1_2=wj%`9{_|J#4iWG3QIp6C?dq?rxRlxl~wYbNpT#PD|~1k8%8%iYB5XWvd+n z2!cjVdlTE8-kYoOJ`G?osJ)Uq?|gQ;j~S5C()= zNsGVza8FSFK6jZw?~mW6>T{A_&X*&9S{isR-?HpyaGN=bf0Q7D%T)6yD(@;EtX3)N z=8&Jq#2(Jft2xoetNSMTa<$RjzjGuLr14X3?Q~Us8aO9gkeYFDDZAZC&lUQ-cjc#) zb$Yv*Q!|lo6Bie?>OF=KWhep<_|lbwJGu+HNA_njKZr(8@cPox`FcMO!cQN+WYjwMitP%G$H5}WZ6LY3JuGld zaS^`O8^T7rBLtSOKyE*t$B(uG2q&K|zoJfa+)=a+Dr22_m;aWOBd7X`KaEH^-Ng&w z;Os?lNX;DF7qV~DmhLjF37sWeERoppZMZPxNnDbX6b-t zLr(6TC0ufvXa>lv@3G;wn??1LX1cIGs({rb>4%vV9WczgLVY`5Ki05I>7qG+Jhl)W zX$86NmkQvniLr8;sBL_bR%M8jCe|Nsn)=M#W}bAISqI`*9X3-MW4 z#4c)|hcjPg#vV|jA%nK%DtQl|uT7a#5<(beJ=bcD-;m(LG*{Fy8XHg%^LO(Wx_eAJ zf@&6HqqHNRVATlEt5azy9VnLyn z&&Pj3^?yRI<(OfgN7ggKM|B25bI=gpG7MB9=aEmE{dK!Eo*o;!FKw_QGAjOk@}jXk zB?EL^R&fMPXtiztKPk>hc>ADHIqQmXH+->u5aV0ZmZ>n3jydSjQX$bwm(AsN%kpyJ zGY9vZ{;S>jkh5kCL_?;1{^Bkuis(aKw1lNv4RG}7%=t)ltmca*X?gs?KFhxp+%*L_ zF+6nnEH}T4!N5pM4jV<2wqC(dEJ@&D%3@(s(!}OmlD!V`4| z6ziQtrX|-=8WL7U?`Vw?A&;MhYqv?=5N$NCaYNaFOPlnBk&PN<*NoGhGc)T-2ns|z zrfCZObH_cbjP`5Ogd;_PVn-sWj#{Z@CS9!=0*lz+5km|dHa$?XsWT>b8Jd@{ROEUs zCvDr@gj{K$bB89U!Q2oOD`8#?CSsXnJ-3F;Fp$OC@D>tx;|_Sk(s`Pj;N}UTFfVAX z4wnXVzk1jyf_1<^FHZu*twyiMZ%2Q9zPmHaA3fjnx%fK+)G9BH8{=+_OG_fnYT`RK z6dzMqrxqTzBznkb;jRSEUhZE2GtbvM>FeH&xRfqBW_}mVc1nf6A*R>Sy{0Hf+h9j) zeMSZA8@&nNc;oQSLV-(fh?*C=jl8k*5LB)y{vv`d&o4PSFnnnNJ<6$-mPG=?o+cw_LSg_U%w0^Oj?$!2gnxk&u zEaBMA+~RxK^hGXiKiP7ty!eH0bpL~UQg<0~NgH=QHD^~q4SJ}8ccsoy%pN7Zo2|os z1NGq6^{<74v8_sd37c6(hhC?GXy)F0f@$uO29#heJ|6bc;}0rHF#oj|3{d{_bK{C` zuEmoWNkEo;_~DmgyVXB>T9ee{!<3UkUV z&|(7rzSlKA4!@OYLExbc6dq4lXl!*-ePBY(?cRuec)0pJ z_DbjdC5PwQ1J_%e%bU9z>#|yt3lIEXu@LpcdC52@bw%yaz~- zXRDX*obcb)7ByCsNp)aUW>`S4oG%QYp5h419eAUysYdBEl|*WkSL}ubR(a9^i<5Cv zDG50S&m3M!#MytoNhclfU)h-Pt3!w9DqQ}06a*^^I9_{xGw#lBE#j-7f%vG?{Gb|26dBmQe z^_+&?HH* zS`kCgkWE0d#z35Ij5#rVY6wpCW78UT`5tgBN?D`r`u8+PkC5UFF)hrePgVdU2_zY!erfHa{#TbBZ|irLte^FkXgM=zC}eXs zDPKsUbdMQRudw!SIsxqwfymR`|WG05j+L~n1_PM9Xc(;=yy?==D)K`Nl_xEACgk% z)JHn{Jb)i~e2s1UAx3ki@QwrJk3VDTU+3k057U`9vo)4h?~HWBJAu$zlhcPrkg8zG{x|WjbRt z=@c7+ww|SiCj;o8-My8s9`smlFFQO+bCW-IIRP+}{*7HhZ-Ir1>h#a>-$&=z;fLI9 zrQ%rG2%m$<*_Yb9)S71>dkl1s6XB$;2YPb9HxN~f3KAHbr>DQRp;VB=#>$Y-IIOBg zj}f#o(h_5lX4{QVwNy0c3JF@wVrOGjbAJ_Wldl~?&Rn2P9k_WXs06Li{tPqC3n0@{ zABI@~KgXY$9t-7jbO_|VA1oGY+1mS%`QI8J4I8SG@o4CIfoUx6H`ebuy~i0M(Q9`2 zzHNnS|G_m*ql5oRntDOx=9`Vo)cB;aFUz5(iy{fD=uphZ`kHiCZpRB{k{b};Ozt-$ zh=m~brdi4BJ6)Fu4Pt#PWLL~^90N;4-F{;!>!MrFYVgT3G4)nnlX-gYYDYgJ+^JIE z?Gfc~3=6{}orvggY-dW|_$?-&o9@=$z@q(>8ZWwH(?;oI+p13{+S`yG<@ve#ZtbE7 zF2&jr{4rU@5Bbc&j)T*CTJQbB^c<@%iJ`@{iGla56hF}y*)8}+fu=)1mZR)>&4Cx< z9tw+FN)!FR_}v?>0dWkd2?dl2V3HF~O9UBKZf?l~Nu~myj&%X$X)_;Ty zl8`TDWehK>kE=kMj4UAFL>6)dPHsOn?{n_4Qp^gvy|6q;e&24z)<_wL3bhJPu#5`0 z(=zYV73mCUl*{G!V1l$G7)p=4SVk>^tlq@5NF^ChISA?CUQ_Ij2)OreM&<8DTUOS> zX(}j)rDH<^Q%Ob%_~V6ralacRk9qAJ+p-C1F>-qL!nSFt%3h?5y|c9WU>?=8yPkfd zTW+hmVZ$lBDe|1a8!@=UHL|^DBs2GN>TJwBd!dFM63yZxhCXdV7GmC~2^84(av!`A z#>aHZQnz0`>D96e?yT?@OPHpkwDjk)VeO!Y{k|*)63sSXmk0wKaEn~lVtb~Py3tVz zqarK~)TKoP>6O}8(FzXHd{>qO32gvNF0yug4;``ZW|AQ37pVE65LmS9A{4nD-E+?_ zIo&I#y~O72Bnwq4!7*VY-`EYLY*6#SjX-Y}vkS5;_GsmE_{)HgVKAzgT={OjYNDCz z*`RZ(W?U!1n;wp+2_?GT8r6weu9S$MNHOZsNut{IV=M;Q3bG}(2Z*p*Z7F*Gf&#G& z!+mdqdnaxMe_BG#ONDPHrY=PNlm{{C^x0_(m3dA%>+Q{|y{rJT73F5z4`Xksboy_Z zpHCCji_sl=xVsOJjxw6uw(D5zAE6=<9Uk%pNeDdwUR#yBXj`VAk+^#qu())?rG*%* zVJZMrUmG-EJ)^ywAE<%zoZwIQTp|l8ZevbsaAaX06xUFa%XO4D9^*h2Q(LPO!jc>8 z6iU-HLU+H}by(kL4&Or&WP~+7aRuZ(yyKr`%|9Z6bnDXBodp$nb&tR!I>-Hw~9Aw2R^0A1iCQXFzS`)D+NX}7* zoy%{-PX{Z_LdIx<>1i0}v$tYmGun8=XRG@=hvTf;39A2apX`Ji9yH&=F#G|f?x#6E zU%q{+F#^6fTQ%@oM{g{7Exu#9D?m;ZdgDhes2%@h1NQ397)6j9)hv-MCm?^E63tKQ z+4*mw{1ZAuqf}zO1@vkQFQSXJ!grl|+bpbmV&wQeStOLcefxV%PA)T_cN%?-^4hlY zp3rNIYntXp!(7f%xh9{|OL?46@dlm?VU#gr0dQXDeq-aQM7!#H1WkR~wl%`Lh5Y5w zogBi->OxBDt10xGjJSv~dvoL3wasi&y2#av*b7GBSTCaTJXdZw5I2Vp1Jps>UcX6% zVlAk&otAy}P6gOD4xTkUb5CpAn*LJ~D!@*ntaEaT_SR-fhBu~!$2sZ|g?lp%cH@X< zR?n@pZK@zTwHmQd(BovsyUSa`g|v4{Ih7;}6%5n7(mlb$z#sJLJsfb^V7%JX6+5Y< z^*F7Uphb~c0|Zynp&#@t>0}pcSuWzl?va33nCcNPf}bYFCB*`3100`sX*s5p><~wV zCo5-{ws9%aK%Iv7#H#%oU(#)i;}Ok30>`1;lFNSvE7s0A9G{1a}3Q}YBx=H8oOUg6kblu8Y>lBXVNflhXv&7CHnH3nahaSO_# zA}-TzY!G(mGC#eW)>SA5OZYB~fozuVvsOfKkB)Dtc5$=*q4Y(;E^rqYE8=! zy1mx#Q|$gg?N;f!Z40!*8r2y7p(73D;9_r9!NN%WZ@-|{5b z;X-Q+lV>4uE?@0^*<{aIH#Js-DfhXu3d^0&nKJx8S|#~{cR~i|56Z>m@Z7qfcxSRDfEaPA6VP_&b9!tEGx3x{cR75%Uo-)Y(z6hF-T&$D6U_0yB;#G zS(JS4*33bG4t8#dnUb#u9P$g zdF5|Y{wKYLV@{%Lw`oL&))0ej_s6qA_NY}1SXZ685R%yayW3)i{*^dlci zI&?TKIvG#c@B8 z+1*#F+?bNPLnk|yompMikLlIMr8%%pE6O$*HtJ+VHu&@b9!k^>;%=CueC}U*peUcCIe94zj=ce3Y0;3N6P_`N_psKA@)>ph zs_uBXA<=J!lO=^f*Q!^jOQ|JQ8jrB=(YLe!rg)vtfKgzn5ml{M%MS#&dhnD=^d{3X zDh28V+8T5?_Zn^OIMueT02YZaPDlkFw|7^iayg%0%7)AkCkO5Vy~kTPo~P*j?68x4 zaB*9<*w?=^=46hgVdNpGj*8d=GD<$Wt5$+4Km8*F(0^;eB5!J1Maao_rYH8?=qNx^ zr@6@6mE@fE%q^^(uu9+ViFq!XtkZH`kuYc^nah$*Fhrp`TG8OE86`wv3n!fcRx*3V z#x3mV*2w0dY7iF_NsV^-J(&VY7z-hdLwd?|x32ve3~?@#_fAQPo%=ZiJEV7kHYUd zu&0aWLndOnU&GLNklwODQb| z^-7hYu4R=t+d}*f7H88stE4_|;y`8-3meraQcY%D0n&?%&`g*V>UMDt%?5Pt*)09@ zTnCixG3R}MY+2rghtOo@KFPqDqel&7CGq= zkKImw5Pp6cKXA&CctLW#RuN>@6})Vt<<}jLDexsQ2~)YOByr4kms)drd7PBihjX2P zd;fNWb3NUdXts_dQjeJi>6PGD{Ek;YDOo<>>5gdBw720hu|2YbAmzCd_{0-ckzX}h zAG$hE-D+*F5M|)>W6?Faadf@QIM`aQ!VhqgNUWHzp~nHr*y-(ew@ExWj~7`7Y`EIp zQsweY6Z46zJEprhwjR8jAP<^hnrzw@;wjBLCSm9zdYg5o-0z49)kkEaq<3i_{@?sx zL>qc+v&YRZkEnLOl#(j#y_LO@I z#dhlmws!p^zkIoDx86u0|83ze1sgGCtzVpw-ny){Zu!W-k#45;LSSE?#xU1oJ#uLo zMEgOv6{~k=bw*h&sHqs`x=W(EKzq#5*;TMi+iWeOd{#VSVCiWp+=yqEBr=1pUX~Mk zu(c0xyCKW73Ot`PVpA-<-53L+)jhID^vo4AC95u50-)qX{HXEbcX)>i$&6N_#cZxl z8x^;FigF7dNn;Tgh&YFr+W+kh+N`FiXz$8#;=x0}>&mD02%ysrDI+R%Xsx0CBO8BO zp;fhWv07NLS6(JXH>%aL9Lk29om;07%(qlp&!hFF)O7NCX@DZ}61&}vgGeWcK7J{^h)+$-0RmrFTEyNd8j*c zRV(n*0*}8U|0w@R((YnGcpT&IFq~$rRE7!nmMrrjE;3t7SDmgD33cU&!q55sMBwAvz4M{sKK|*4*YF!%HkKC-ttT$m^Z1duX(!mx zaqXGIKkRA`#0T;n)gX^!O!IF$O{>w=SEgSvJbq7#ikKsih8lU?LGfaoGo$sPz#Jf6 zYTDx+EHu6P_XEkFT3R>cmrgO?!Y|tjNH4wS^NxhfE3UJyuy+qX61T+wQjg?sZ&HT; zOjbU3U9Z%Ayak&MRKRRVjvo$B!<0riI{_Eu@%JP&zn2#ORrhRKMhADC)nd?@TuMN>KAcg zSS|&$Kw|cDhYgFTP#pte;aP~0Vn#NcnmAEE{NN7kv3ej_cG8&>i&xd%riv`UM6YdFEV0pA_5 z;71wus*woH7 z6euObz0}?pyP!(b3^2t3*r^cP0B~4eSOY34*7vV!jh#P;A}8)-$JeG1B(?S6Us;jY zb;w=}9-qSm_k(nI=7x`Eiia6DlmE0Ya`yef5m;+5?G1;b1bEeTEyv9zwS&>+N%JJ_ zl|#}X{?KMPWJbACY}!u}QXpJ+lO7qNw#xLySjfO&%v8FT+o9tFsSHAjg}o+Tf^i4E zVw$|U9mXX`BS~abkJ0S6O5_{@^x~U?oC2>+i(FgGqw8(K86zjDtc{Cayxua~M4bZG zAcI-K*yr8v>24@46C84Y7UOO`Bb7anWg$pDO;Mb(F`E`GPh;rpkK?D69biIaEL16k zw2qh4=`N2uLvrekmvK$I^73l0QDkoILf?5xl~b#(`G3C)fZk2y)s2O2YXjR;&rNJd zTtk3j;@0sDrkJ#g3a;MOpjDv7X>dN9V^oYHu^hE!&MOa%i8U2&}PJH5~mg&nZ7h8@X5Y^W0j@I$^zy! zQx=8}$5y57xMRgcNN>S%#SaX!TCkR4yTKR8#srDa4ClfO<{uIuIqWI6jZGK1J8;#9 zk^PqC^A6KM_s$4^&c<-UIqOp-R6<5UZI`OYgMR}TLf-W-Y0z+fs0EJ_Zy)(Q=XKxL z5^Ouq)c9F~W86!7=ABmaQR>_4$G|9}DH#`uEjT>@dR)6lGbps+94wnYFqACI(MvPJFC9C<15750mq51 zQu>gAu!LH34_<5NN=z{dNue|R?c!C|x4?gvfSS|9jGP&@AD4&6NH-K`#65=GY0Yx=qZq%YdlX8-J(N8xZ&da-#R^n4o9CuR0@&Hjr`0$^hrm@soQAH;%(nBny(A!6b_ zrLqVrG{gX-KPxKyCRyDNCcg6%QBSnr&C!qCac_x_%k zFvO0cFOt36mTsK_D@rNH3^TlZj*PREr#VPLM7r*?J+*+vNu+p!lW48QJZ=OFA{6-l zwEZL-P?f)B&eA$475xNO)iNig0M>ZIG4jA^{j!?;=JM3V=Q*x1L5PliT7m9TX8PeQ zYZ+M|21RY+vA25zv*S?m^3X+x)b_)3I;pWJJD)rOwH!(LAX|g*m|kPHXF-d+akcP% zl({xWS(yt8tR$rOuFE({a)yd`a9cVNNzHKHJ^tWJQaJY&OAL{*^~c%|*$P*ov{#F5 z-mZo(g-my^4=3Xp!8ohl_Bmp{oiw2dr6HFNulRU({7;-ezzI0}OQZ9-T50yARopRS zVVom=-Hx+VJf)dDH_ICt1{If-e`L51koFl6lZV9(pXtBB6UzbViG>L@_Oqo7dz1IY zCyNV+su7n`p4{*(YI0v+9-o0g=LrZM%rmtjA_9+|=@^Yv;G(T;{igMJmT6%IUAZDB z0v2DTW{<4}5dW$*aYaR=nOSxSyK(F6>z=Q91shL1eIZE7f;@M28HNkUL%K5@>v=hK zG}6UpR(q6TzY#gDj4y)cc`Ldz*NkY(Zfp=4VH^<|9W9%x={ZYDLj>f}$^G`$o2Bp- z4$rB?-rPwe*>ONbM~Qu{Q-Zh3jI2ZNj63#b8K_qhx4envhC0*M>{9ZN2KXT|ZQ=u- zK)MBPy^g4MAD*bOLt}F~hLIobGs2`fquFSwBa}Xde+{1hxSIPu?J!)H%o(XRk0^81 zxJU9+j;K2_Z3Ti(wDY_7-62(3&;C1LKLl-eG+)Vqf%`>MG0SQMM+hOcYQ%P>{~~5XV*_TJcV@(K`fjHi_C^T~Q>L%hH=!j2#+AJ_7Ewgw>=X%A$QwyUdWk1{jNgf+ z?BXo?lbnMW_gpSrHZ+&jiQc zR=6D4;p_&Q(mSn2w%0AETNw%m!I9j@(YuO6A92e^aSh8Beh=bUKS8I(sL_gl3vWED zm!p^RhwpNfKs10O#)t`U@Wem_B6aCQi}gQ#ITwI9x!R*kc~?fm?TJZirv?v{Pu5uJ z5Ra$@*!xJk|H!@JUZv`PgSQ73;?Zs}JhvR?Bu(q9k=McWa8>Rb$~VliU$?&#NjosB zyZ;#-!!)-sz?(~e7)5lq2c=R6L!!>7-+c@m%r9MkUoWSEPfA`H((F_Vlh2!im5Nun z$aXVt5SYEn+y6FaH5*7Ypmus#*|S6KdYE#%Z8V$Hm^R@ax|ZJU^Kgv=45NqHD0G z8=Ak?oYpc4`=P@QKW2qiU0Lsd*7U;EM7_{vFC~8v{YUmUMK^|SQ>6li18X=gIGCn= zZl)7f3uQ3=C>dEF6;6ykzZfp!f#)`eHwQ7Mf6o*jB7gel>l!k~vaOPaO=8j_vfn4W z`h8JeC^ugu=$wX$H6^_ zha{p2Bv<|+b;#U6_h_59qJ_7@3#(i-tznVThLhRg8r2G7QYCRx)XI-s`NEo%h2&qq zZX-G=`vlm-zbjytGE&_7a|;VosRAzth8_$cRj`6dhnwLZU%xYd1sgscQIQdtZ>PM| zIR649P3V%K3+88a<9o|ldYod^N2jW1-#2*rRt9fUHL6C*em0SK$%>HseIa)bFMs*R z?X?%dYoJqj`yqa&(Y)4xJ_&bHXC#K|$+z{MVmyTM{R84n9maeh@!tSI>D81G@0{zH zlg>{W&>dB%O+I5sfQ*EQ&>mS$7Cb+ zp!hZc9Wg47bhHYErkK)EcF{kR0NpH4T=EA38@#^S{ynQtCVy&{z`1M!&NFnI*>}wQj9aRNH2tJ9 zPm3QoT3NQLEYa#(csnvaPoGZ`Enh5JG@T-25CY@)@@4WQymQ^bf||237dPZjr{3RH z1c^em^UW)VP?knrFC-c3B{FiLL>THD?E34|b6zZz8QD|Jo?yVrk0WgGhG*ttF!e~k z3jRXSsULB}e{bZVPw9%5+Lx%)GMo~_nD=_LPh3Ex=No{v#?8-4 zcVhzs`43mx8;QtSrQ>ULE(bSo{qXAFzki)`Mzf z2>4`1>R5aEx_0LoN}NwQsnz&w6l^F${Ug$f(B>+s1lcaEuy=87{Gi|vn zG%ST_Zx!ot3c1MoSMX?cOe(zEHTb=MYQfE2^!rx3>ubtS* z#>K>Q&Dn?vATNuOo(XjAzcpRI+qu7gX69@>mEEzDkQd3{m?tjvp_?4dpX7SEmrrkY zp1b>)6;n2Isz9-DDM&SL7{D|ED`Kg#!#%{T4x1eUBCr#!;Nv;s)!&t--pcU;-TN(c z);L&^q5hZ~xR2f(L>w1C4RM`XsTyLjyxAIK)nE|A47d4`oyXu62YAf#P$gI0(>?6N z7+a#+&0?kJulLx;HxC74q3Lki0Zz&N zespI^Ul0v9v%7>LP>osi!}6%7W*tisNSgP9$FjF&*1FYl}%D(-K2xCc?SU^ zAM6m#zi>j#qrZt8mNQhs#=EkzoLY(FcLmM%FThLpowNQSq56(K`do9az3APt<22qk zHr3>3B`E98Xkyeo=sSs7G@NDIjV_-}d%x^O3m%`A3}~N9l0e7IqRd>vFWfrI<{5FJ_rVjCsX+&N2mNvo~fa*q7K5->XxPpcD6r z8GOJ07fdoFMBVQF+SW1)wbt)BCY=+ zowJONo*tk5k5=LndQUs_l1a_=2fU65NG0%2m=Kn(>5+! zCusrBi}O5hB7>_~k?lu&F=p3$So>G^ z6m5Wn4a>2?lLJ!0<0Q&dJb@9LW~GY%ZyrIt5HGwe+Qw%PyAeTW7c3sf+f-0r5PAW@ zq2^0z+mjN7dN2k+J@=wH#9~3#js0!qX!R@I%FPfe4j~YuyUuUZD@U>E&kT4ujuIXR z!U!66xI4HQv?ImI@ZBZ@Jj3Og7~jGwm2G=0Q|g`|Ue z7vI5p8Ld3wG)rcABw?oqq&n!9>=jwH)0pHJ#c_lv~>q6Ei=` zK5)oB61!n<5{3E^<`o~vUB@IPw%LBSa$rVUsp6p$NBxT>Lz7XCq8w8pFjD;#uXcsr zEvj1OMdqAe6W)ApAPl+YS$n75S{jSF$a!Njp?qFf_4~%_x?l1|l z))VXy{27e|ZR9LBjVc?eu=CD8!aLY(!y8$D?&m4y@I1QWc7HU4r19Ds)eYkVV)lee z`TY(WG5vo?$pl?wMiZ3{=3`okCQ2H7waY-bkaF8v@T4?79WOAE(#8nC@1%ajsUFln z`uX#z#O}zAduO8adBZG=z~X2dXwTS~s0c#5YlfKGEBCPSUZ^w=#9G+z)CkO3k#FK; zO@5at??Gqn$Uj)}$fN2){|P3P?@>MjXL0Jg%2n>kTlDR@N!0BDnDjo^5lJCEdflHL zZ7`ir`4U2se^X?Z}v}HDBaJSCR&Lq;8 z8-N|G@Gw*PLr0!)a{OXn-vgIDKi`1{D6-4_AG*FOAkJ>dHdt_I9D=*MySoQ>cXx;2 zjT788KyY_=0*$*{fZ*gP;;8O@iR!pQS1 zZ=?2kVnU`c{5lusjxsY^SdCkzM{D%6d~!KGdZSBGl`E_l@sAf9T6V=|9Hv(5Grhm^ zHIJVC5nxeBCzlNpNMX*zt1QMmXUt%JOgJ8^tdN+xE;90kA7OaQWZ-KorSGkgYhc4m z>Bg!=N=^ixA5!j|d@z!i6?GK=s3p1UopV9VX!EX{@Dy3L%;l7>6$XLtKt z)HgbiPVDCUR1)YqB}`C4jQ_SW>Oil+_Lu`_4^?Z7xjNM_PE}h%>{ufUm36OmVv#P{ zKjHB5W7rCeeH2iX4X9VKi@Vh+?R?WIyme?`wis2*aC(rc(rpMw9=VSH7<)u_3jWQ= z^6>~9kH?(aSKJ32Sk+iY+ULZYhdq(T^H@xNg15dsts^j5jr;FEy@NsqZ-c(9{#>u? zcR#W4@et6{FTxH=xA?GeHwy<`;!_wvTy=}pmF!NQ#F>~O z$X0Lje=MSY_2a#>F#IKi;bB9@6x*)S7xF*b2_@^-HJ?~T6{|@pAs;m@0@NcrW&eYP z&z(aIRR2I1@`%B*y3T?MOPovaq0W~aYj!PeAIUv)^D~5EMX!d|f3fH+M+otmx9gOd zO^OIMNFVDKc1^RdgHPYBvM1N^7O+v=+pMJZT^>tb0^?iaemEqP;++Y3Xr)5>FpjY& zU&|d3oVT%11e3LWOR=d4&jPE5QYX_y5l`7ZuHa-1yw z+XjorZ=%YIaMRs&=Hm$&E*&}?(5``R>p`!(hr-H1v2gCo{B387zm!PMF|s`*or`bb zM#^g5I(O@bo8PR7zT~st)%9!#k6&J0#j~lN+1ZM~3>$ggW+DEy$oJ{E$r0Pv`R^-Y zx9p{l-m9S{`ek%(?T`z4K>~tiaxx3IwH(%UyNp?vwgv*l(j;4I`y&F#hyB2 zv3Cp3;|(cn54w*&YVyCtaXd%ZA7|fXbFaGL$b}4b^A)fX|Htn2$3_;tOIYJnibrHp z9#=QW?aOVmv5Ec1qlwYw+qQ@Do7!1^?Q>UtLL#{|W)q}SJwI<_uQu6>M0FNDxS-u$ zh`#)X#z?7Sd!Ed8+(Oj$QWg@n28TA?yQzfneT8N)BL zBHwAt5jv<9h@?waB=>gmARjwNF%f4;#zXnzTasO=YIpHFo{83hw9r~H8_~kiRFy9l zL!W0LZRLFB4aH+&(=>MHbR9@S^6;Ky4dI-3ifDni*v)R3$I4K6S zsR~=kC=2q6|1d2LDhQ$oQTp*&>XY;*Wk}saA^;JY6ik5>s+2xFCOYi$H{iF6pz0tZ zBBCHjWoway_3USUw8*5><#pS>^js&4gC>{v`w6;hP`XRRCln%Zv2QS3l7X3J5dgY^XNM)kucleN|honh=F^j!6Y8iDGCv=)}lC6$(rh92%~^nrfKOVffmu59Ct%x%ON z0E@BU1SL#=8CL}!TgF~+#OCR9@Zqh!9$f>OLfTi>C2|yjWjRRTf1C*bMk4edKR#_! zX2|NwfJcPm<<-^^H5;D1LRhq^ZQ-U}iu!4d?Tcc2_k!Wh(Mfo&3>mbg36k%%E(o*& zGLDpWOWP<=BcHg!v5wjsuP?GNbwT^j9+Z?w_|_fl%d?w>9935RW&&Rb2C=b52F2F^ z&V3_L2Jrc|R#{aC5~>Fa&`9EJagkUMfp1|LV0$1idHBw7Zk5695+Wo6y`{Q==XCuL zE?*&xux*Ujs3)yFspt2{aC{kLM0%>vYvjehPzA5Z!`N_S{&uXb&)|Ypc(|(5jFw-> z+LZy8wvmB=CnO?{i;K3-l9tKUTXqu^r* zfzxh*Ko;#Enq@FM^H^r%$75ms)c(zjpu{d1s38>RBGOiwY zn2cn4H^#hr^H>efp<>9C@op!>PQ5`Pd;ZS={BIvCZvcfWf#nisqR3ita%`Dk|i2!+#gHZ}(Uwci&OVDf#1=`;B3J9;8eYojXtDE-JqC)Mrv#Rf_Z zKU!m)GS5I0G!ZHf9FL}+MO$rp7z&*cr(rSF)1GGmv*kL`c-KX1bEx^W3xzLliw>vA z^~(8-K#;tTn_zX1L=32Pa)Y4LUxq^t)h1@(hBV&5K9Gc+hZuzFeb8qR%jW`tX7mfG z-wy^Xp5DB|5lR-@q=~6J!ZN;){F7Aw?Mxp(0(rB00+X24Uc9D!fen86hTn}?_K>@) z&nrZce!FpvZH=!Igo$304A9sgYZ5zYQ`zp+Rk)3Hp4uG){mLZm2fw&|X6fAwnL3Jp z@U!Uz8nxxnjy8ztMbS4Gdp$j0L??x_e|c0JY460pyJ|8@Il#i697_P3#im^gt*X6dTv~t8ec7Jq~fs?{Ja?Yio4BUI}vgMnB$btT_ z>}LFC5^4aJ}`R>GGy);dcLLHzm7fZQyeH9hkJr@r8 zmna9?WcmX^jGU5wB8p$`WAG_)9q^!otCH?}k@6__YaedWfte!h=|8n^x;tpoH=Pq4 z0rL>|Dn{zXl#MpEW6M|Kph;5BDlY|^XhqNpMr{@CAk=CR%y-8p1jSI!OS9%V>Es7C zFh08P<*G(;qo|O^V1a4%3rsC_inxB|P9j+-S?|dDADHnXXk;$9FO_=VS&bX-VWa+Z z#t%-0?Gwk|DE61!@*oQArybs;wRxStK-07JApBZuC{1$SXLI|Rj?03#8m-j36FuL@ z9Y*ZWA@I;ARg^sRVafFO?#A)d4QKe353@>5Z(eI0^lj+DZ!)IJ1KZhe6=CL@h=fDw zV=L^d$F`3Jzr%a5G&{>Yp*GeFiO<++Wb~}HZp1O>7aT*ffi{F~g+FhVy*4QTwo^74 zHA9Ld3JyB+!Prs6WyBPt&rS23TKByBCjm~TaRoK+m{LRQ(rp3(Ye~Uf50GG}(SF9* zBQXCD|Nbi$sq7$c0M^iKss(8S7I_Y4c6Z3k-04~fm9rK{9vs))>0DIT&n0GBvB63w zQ#)1wmpdUTd_-cm#6)>7zV;(|t$gq74b)VM!rIRh958V^;8nn6kZo4-(%WJGL<*zK zTQ3gvJOmjx9Lc?7bf`SCOZWfO>;L|*F$X)T;i3)6EZ}&|mG6oPMedsDD#K7V!S&4r5G9VaMlC#yD72a914(PYCP3VLiK4l3V&{spw&DVD*wj(3I3O_Lj>*t6WI9#=w^w@kn1_#ao7r5oiuNTdM^H! zNqUZ)xzFLC!Cu8^bMptrll_=@YbiiYFGdBl+_xsXfA~vJN4LhRx*3fa9eKeGvG@4i z8)zPrZLeP=3#i|@zJl8iSb991RY+wN2^@(j=aoi{>*f2S8)99Bj&Xtq`HBH0GCoMRvzdc)9!)$N0{wQO(>i`o9=9G5MmN}r1xb-f7 zg6+NWnDH}&5sj#KDOEDumh><1hbp>dd6Y?ylbA?^%28{^wu3I(p<1xs`ExxFgnwEK z)W(CP%se%yMR9=Nd-qC|wlslWs|S3+zr@$ST;VqBCFA)`{tr07vayT)1ssYJ zd*1e{li{h*n9xM*$U4q!*DOQ}%zU{y54zP$?knog3Q%f|#ya~p0|>tOFV}wTGDI%( z@krmRrkFf-rbJ0%H+2)49@^dP8CmE+t+`}T!^0=7L7gAFp6Ren#S$yy5UvgG^%#wC zfEOC{E4XL=`V)+^F=vy`G)%v6J6e~Xjw%ZYb}KBBOmjkwLok578hbI}hq?&+>mXWV zlcUGj)l;cfbQ=InZ@@PqM|#Z4HjUSSS-TT0MqaSF(eh5z&@PeqOPll!Cdm+dSY6RK z7M2=}=aRpv4UE`4SBn|Z4j|wcRp~qNx?e)g`V!C! z>&Wt5s&|_yX6_TjiD+2&gf07ESbKR%keqZt$3zClU>#v`CL8{Ykej0zcdw=0%Q-hw zQ{V>mi6@r`V1K&7{MBevXQe7SK#hB5zjPItuM(LLl|zwX?R9Rdd{3?Y4(_yHhL`40 zaQRG7=l|M5<(Z;!H8|U{#~4hB+>jvX2di^>M(;Dnil0v8X6#OgpTa?@N6>%Mw7Pe5 z2hKz3Dr4^dj3GuV8~vbs_srx+Rs*T-ko)8lbuL*=l4)G zw|1eE;y~IMSdTiQj(n^|>s#O1WU#1yD?9$_yI5I}Feptl6R$36To8ODjKIr7lr*}& zcv|b^*OD`Q%?lGa?%v=2=+is6z-RO%AiX$T4lm^073HB0Pufm*RUMGpTBkLVY&t|s zEvF)lT7g=uLrzSc%)GI55E}4+*w+_P!AF3UbfBBm?Wy;R;-t-04#uuTOcO-envLPMN8WESkA6 zM&X2+o>`8u`+mu=-LtI+>?@FT9c8&J7Z2P_rKD!lpUb>49B&O`X;v~qrZEF_zO;WG zzGVSiqEDcXo9A5EKTn}8#?2~35HazQwsuJ@xDOVD=@GX=_5Tr?cO3rB$zF26k%ikW zth5HM)07rtEGsfxW5BogW&Q*qFnt>~`K7g$!&qc71lkc1L^!~G!0yCf3KnMoGjPNl zs95CD-|L490T zl;;og_Q%d7qpAJMant;4xZA?SS$Q}oJY9>|?>^>_8hY*Cqn2U)8_HYy z>sFZ#3>SjpDDWgBJw_3B#w&_2d*o#4UBg4e`W zeu4Rp4PLwSHQr(^;X!Uwurf6d9T{cIMP#Sk#<<+3XNeG&Jfcz$9+@N>2>F zG=I&p@msL>XEej^@LKKH9Z|`P>Tpc_x+C>0f*1P0kG6CFyrCU!lSbTF1uC-i1V;)| z(hUh61;7Z^Ok%5GGJF&d=O67!B@eJ-wo>hjiV%Tm*9n z6Xx`De!CPuy#IWiwbV$8T}Uxuy=$BfHs5h;bEsUB^)s^$^>24U^&Jw4;mculrM4Ff zUyr^hbj1d*D4&q7l9~5J;HwM4TnSc7Vf`y&Sfu~gsZ~IJA(5c27w@vfpregGa!LQ6 z9~_F0Ae%p;EKQuI{x5bwIDQB3@)Zd|dnq1-Dpn*KZrXZRo>|0U{w7{N)>GK@sM;D% z$Sb0Sc;{N`-U(_sI!;!z%l0;XNxh2DNUsQ?f2I-n;K<4r$Usd*_TJ^-GJtx^wrs6! z&>pE+uRCxcbNa3L8tQQ~V5!wW;UjcLakE!#>@i|sEQ#1U1T;J$_*`wiF)T`Y#(B5g zZN!v*W12s5mxmcTxV3WfD`Aqfdd{6E?8N1T!@GZctVg^S-EWSOj(a>9Vq2jE|F@|T zj6^8|<|XBYLSHl$jTjoeE6Bdk0f$26?c(`qg2$9Jj#b)sq?qutP-a@F$Weg=2d5{& z+bGBw#G-f2Xls6BY#h*k@Ahiy#R9qMKZSY{xz3qUdd^PB zZH95au#N{==Uq+bgleg_C9rSz7&+bu4;Ij~?a}GuY>(G3m<% z_wF

QVjkbJE-u66}x$&oJSRf1ts*zrTCVCXMo4sj^w~zA9RKCOq zn8X`xaq65cJUq*P3*P{+9av=Nkedt$3_N6~B;C$`DPE*aac;TRl+w=7tt>+&agx3z z2msBuZG$06Dm4)V7GP2Q5m_uGz$+L(fcbO#)$~|`o@rweBXS^1@TxjSUGX}gzQqd} zSp}38^^OTQ5IMrOm$R9$WM^5Gnb}%crotcE3S&EB<}cXyi*3LSW~asyc>Lh^N%`|R zg5jU)*v9+t4zbu!v>>PLNi)`N=RHADyf}s@I)453{FuL09-&{9j$~##QK+fA`P6Hb zGVm#!58ZHGB$Nj`Ezr=zZF{GyV8ynAB~CLfKnHDy`omJV^?CcB;g3JrfR9RiQgZBD8VYm6~sktK=> z@Iz(kT!%S!GY`|qx9vCEN)P;RZBmzc`Raco($LYiBvmMp2N2J6M-VY8Z?8uS+>IqB z0u9XDZIs)EGu<1$bH#~fB8$S*cEUQLlkC|5G-mBAZWOFHZ;yk)!D(ZHSeTIL)$1(Y z#?>PTw@LWPOYQ0v9oKe6oCd-gd|uh;J`(_gjU1-No49W6I78x=W=obcZl-eVwU(wz z7pUvi!21!(31>uzBw&IL@)1b0;L*$bqz~6X4J+S*VLkIc-T;wC0024R>bY6F{LW2~ zFgs1^LqkGF=zlaJwuJGIw7vkUV34=#zP*YVX_i;vnqdBl@HZg#Sv52(DuA0g~KNfut^Zkf+9Q+3|&s-DI0ks4t* zUU*zYDq}$aQ3x?Qp56L~!Vo#gB{*3)EeEv4)i?Inn zx|sm~&FyKH+mCTV2YK(}x#g+AKNQ;X)6wZW{2_@(QWS+&>&W<(ICb^bj{50B)ZyVF z%<$W}xf7Xr2wj=>kU?Su9P_s(PCjwzXnhQBgjPQP>1I3FqQ4gL8ik%b0s=glIcaoDNXEV2+s|%+YzD1Ndlv* z6qdb2&0zvliE1jS0LDhf&o%v=WYhM{D1SQb)gEO6{q-Er_>dVlsva7iB)%QUiOpFL zd60kxyhE=Y?rQy3>s-AP4_0W?+2c;?M!M~xP9bzLk$34MB^?(8qtV_`bREi*Km=Zk z*17?3Ox^PjHx^8bli$`i0rrzyhx0hn7v01UIlV zOzKV@vwqU-^6?{mq3UWccJ`hHs94!}5gK1D|H6%ck!<-u#*!;~i;R3lwGSd}w8}O- zKfpwfw!0dYTZ_l?!>#l3vEVYH@9S**5-_{bl$|bDP0{3I-QF2ZptyKkMBF=hx1TW8 z|MoP|06kpS8P8ZuXR|7YSh(#@yo;&h`Fn_cQko?I!sL05-fF`4Rd1dHPR>`$cvwk0 z9)#meFj5|dcDeXdf8fMRF5fUq5Jq`x9dTzJmXGh@vogbi!Gr~FiXhrkoCp2I>4Led zfp2O6&K*?j0|9!jGw zU29Z5jeB)u?}YO!$u{u`Zl+qVCcA@zlrLf>;bfm7XPpIuKVZLaRx`#(4EJy74wg$) z<6d|RzU~Vp`$R}6z?tF(?BP?qc=?3MIiKNFD?Vla+2Eb z5x3_@ef~{)I}_5`>pseMl_a9rDForrbOOsJe_lI1kig*iLjjS6CB)#l1Lwoij@-TI z{s}I7KeT{osJoQ%sYQvq0ytIP2w3-S>mty$gmu)|<>K>Pq`Hu{BE#p+dKg*xp=p?j zc-+7A1!Vr(EpAzX4<}H%8zyw1-`A`2n>tPA#yP^XGMsJ-j^Td{?EmmLe1F&nj(qV8 zH1aA1%D^%7U2;=%?5g?DW%WUdhgF@fcO;^=eky_>a73Cj{g@B{c?q zxbvBcg$=7T#oihJ;9 z=xak#g`Qil3PZnys!77L$}ZD+4o12+f~;pnrU=1^@|i@g?u%dNG+LdGperY^4Vf_VGex*r54p8N=Z)B)XFZW zdxfrlWtz{(9Le0A6+H*(gD#ZEwL1aOZ(l*i744Xf$K6(|ul~B)AH@D&@(nqF2d(X!w{SFKMde38*w__(|X>ReTSn(Nh3xh$Ul5T})f=xue=8TKeW|?s>2M-WF z|L(qXOwm9e@a1mT`P~k)85kSu>f~j`9C?k)eUcfE3L+L~y2+N>eop{#VGsa#J zjQuQ#0{u)hFe$1@brwI6L>kFN_=~lU6m3SbG@rz>UgQ%9Z*xD4v5M5i3e8{cXo)tI zuvb|VhH>J5OCu#!@bIsj9g_WQuzGkua(Mm7psLSy z$89I=K3c?QvWcPXbM{BBu#*vtAYWB)rVYMeXF7FB2r=ZH^Y#@*IxV$gpi`2kmZ zP$2f|P_`Z~cUTv5h~IsT@K@K1tk@Co^ z#>%Kb%TB&{L8sx4%)v|*h6Z?Tj@#$`Pm{N;?R+Qh(vT#d^fSy*u@fHl=WorVS}N_A0#dcHVBVJh73=H+`PWu`=6A zm$u}2w|;^4cQ#t?fX?miwAlewRGzTKIgR^7Ivyx55ZF2lpwcaQ-kP$u(0ZhkEGDeJ~erKLL? zNI(n*#b-m}4rD$fzIo}t+=>T7ph@|Lr_hN1*p}Mb%oivaN-uzv#c#X^vnF25M%;wf zUE$*GC?`QY+sbj9;psabbJc=WH_NxE?diAautzJq^4*l zol*m@i%8CxLblP;BsGD60Ns<};4TOXac(C6+mS>9A!)T0qHj))cA02h7ygyMErg&r z#CJu;HBnvxvs}xKDL>KwhPQzMuqX5356O0OzY%*MA=eVM5uw0pjS97t!#Mx&SB}7E ziQ6tI1wN`x^+%$3c5XBs5MTzg(vo<$RxK~SYoES$ZX7oyGpeY6fiR-~9Hg%`Ob_U$ zhRbx9`t6%mG@Fjcc;Kr5GdM|f6T%Z)navo38f0WdQ>%)dEFQ&sOQQUX95{hNb3?|U zv8~1MLkU!Fp}ay%!n_8ohbQdoN#wfUDRS8GF^WHO(>`kMV;?kK_e5G1=heS0O{v6Bs=)zN;3K`ZK2>*_*&GMV2jaqh}1f zhe-JR4v_*U*mMk2ihsM29?1`_JpBee@dw!&NWgGh?Bk+8=uf$9eHDR*l;Mb7KI7Rt zf0!h0ToQRVoxQxEtX+}xKBN<^EH2ld)9U()#i2j!5GyqG(4Zu$Mc3Syt^wfBD4sM5 zygzld50&$=jl@V+1*Sy9ln{{OT1R#~qOXJ2u2U1v(6^_=73`_Q(gp(O%YjuF)`)|x z7kK;XIL)fIg{UwQ*v3upga$0dzO|Z*@XuUdBb$MgBn^nTf>%@o1q~rY$jHVW{6>l{ z*JcDFSp(l*qm_sSx1S#B(=T%e(z9N7yKqu>$IKJ$9>}R^LY6K-fcgz)O~mm1YH<`Q2{tP*QXUaI((a3raUpu02+Szt{IA zehjD|L|(T9#*+{XZXC1i?+`yu^Yxg;`GYsmeUaJW4Z*E{SM4;fQ+KzKxKKRmT&OGw z`gJMpL@*JfV+(?j6Y0t$+obIiNvWu36V?h{6UF{rhK{&#prSMkBQbVIc)DdIw93Zi zlDkPYv$?hJ9c^FD6LnH9x=HUOkWu&dp!m8ah1wouAPM}CwN9;3ty?;t;Wu6{y-d5r zl3IDbzhY}M8dnC-qt;pnL!hCV+oc=TV7E{szIONGoP$GkZ!2kV*M_zh+?>KNzdfXk zHcCnL94yv|ym_MMZKwNqfNK>-^ZtHteN*_h+bwm?qkyDM*ukcBG4Sv}flyu~dIwx` zj61V>inlR?1zW&o7vM5~guPORwcAeMz|f7hS(QpdF|$t$uD3vcRZXaLio+nSA!a;O z!9aG%ffm9Q-1<2tBS-@QFP%m-C))W!` zw+PvS3lyatR-b)z<@ROIKPLFVT#F_kq7_DiHVQQ} zK@4UY&njF4HG7354eM2F!^c|c4c;WO@Mg8PT2bPUA+nSKc);Blul~wlmI&Sucau7E zRAb5e{-&3=vN%>HoinrBD{5maQ#c0{lo~l zoR_&bhsvpIi*WP5%OTf3R1|zw2q)PuIT4g(O}Ul{mM7)cfhYg&9eRKaB&AM-mO{;i zH=$W&qU!fKHCq4Aw*V|bg#HGbTH&_8ov5q~7w$^fbVVLu?^-Is0Y6cER6r~=+%Ejo zACI{R%YT%iLi)sI1fY^<%zCeU4J(|5%a7}l9kh10>olx`Z*EpW2Z zZO6-Vi>sHZmYBBN?WdxOx{7)D%_vI!!ItOMODMZ&aDflv8biNuOtyrhrKpW5*HjiW z((3N?^p++kk{GGI4({}CzCi|JGXqx(ssRyN@#!}ml}@2wzmXY7#$ZZ?lN7!*!<0&QwAxF zktWveAT{_SxzA553BR9L1hsS@3>JwW@?;NpK!n@Ll@}!sXnHbAe)@48RRDVj&BQp; z>PY<^v(ClX6@R>pfn%vmT1U%#Tgkzok6ZoMbOFOYZI4!B1eygsD@`u_+1&AusM`lq zX8u{(J#Y0lL9K-*@wCrsc=j=}z}cLejWfI|Gc0C?cBiUW4I|rBQ`Gi}>c`_xPr=m< zD1o$)6>r#>P)fGBOBuS5raL7~wd3AW|4W*9z<~t|XsR?DWjX;wC`CPCYt|efRRAZQ zSy@rl7;ehEZhbe2fJ0TpcAI`*_yPq@p*v1C?PE1Vf?9#n8Oin>O z5TevC+wgfZazgaV7?zlU=*K{S6aE9^kQo+Vu1AbM#v?7=H+#9&Y^kk>%XlHJ1w$}d zf`L|Msj(kKAQU&@F(Ncp!cr|(zueJ5(x6)H%dGf^FaG{B5)BvMp4N{f3ToBZlK#^G z`rou{w14Cjb5`jYx?m++b|l8R7{5rRuvU`~Ei&vpm|QwmbR;C)_6lPMT`Yw&rP1mR z>7=p$>lIdPOng7{u+Mf!9X>|`FuQL}mH$i-rSIiX z-``LrMZtl_I9WisV=9=d;)yijN5cWC1G4g|70@!r0CH+@W7n4_yx?8!eQpYsLcQj= zF-c?Ik$=$n)VUT{74#WQ?L^$!jQ+Zu&L(LT2LCG#ysG;C5vAmbQ)u#Vqh^e-|DH*9&)g@iS0*fLeo3#jn=slQhyf~KT51ElS%{&qndF$$i00W&NxEFi!PjdBG! z%q3=IkhK1-|LU2PS7ogsNJsM2!61C;sj)hMPP# z)PGfcm}b}(jgCd@_mCw+HViRLH|6&D2DWaQ`QxD;QAZN{mU#__O8XWg{V{nc6Vz2f zza?u5WC{e`_M$A^UdAt1kD4%0NM5WWsw2VDvZ2c9>2vwwO(meHQ8_yX>6uKxbQ||- zm^CT-AhMjqY)4VHH=%12-(IsEQD6L5@iBH{t~hUENm}0>!AV;#&bz|>I5~=I$#eIU zQ0x}+fJ}T$kLKJTR>;<;d0wJq`}YsV)sAObJq3YfOa@c{1Fit?)HfHuD#jJ>l9i+s zOcH2LGqq6lve;t}xAZagty?|(5>rG_b)O>Nr-~|q{&OV^y!)lrqa$ojH750#ESwxA z>iGIw!|Az{>2JkbWY~E1G|r)wzDW{)8MMd~{}Ij|Nh`H1$HuVkLrWYzuvHj+WvrEb zxXF&g87|OOSKBw$^;!tm;T?WWkGaaT+U{MeYX8d<46TOqnRhcJa%-wjYb!*2Pz)7w zCv*E(=z|3I*WgRc>rB8b57%mdBR~9P3#5whMu7+KJ@2Wl*JuE5pp>Y#Rzw6Avqm4W zkXs1$o-0@Us!gLWB=`&gOfq4*{7zv=tWme-gqm~gP#3n&cc>?57cE5Q4>Rf|g@9$} zQ*jSN^s)OOs5zZ%_wMpn;oF5U?>S83jW>ljB0{;*oyq;yIB>H==Zm!-$wQeipCB*Cs1yxopu#wI8rtD`C$F(xazfBw; zel9BT4+RJ@M2HoOb2XXn3j-kVd#xDx`3vrck5y^csjUMM^pM|Gfl=E6?YE&$-U9V1 z2_x|6d-vUAR#bGn>B^e~abwc?!+ZQ3G`9M4v#t3yCT7>ob&b1wG8#$+jkoFPb%@O4 zEN-c#4L-Ff;%9vk{Owz8+H5Tr99)XuheRF=zn4wJQhdC|bG;D5zpt8)nxu7`P_3`# z8cGGFxFdq>z@AdN$_X^^eAcraK9Dr8Kinn9_kQ2MljzXgeu;k!j2x;?rjtGGMGV++ z%Cb=iQ@+gWZTYk<=!Vi}N7SvSywns`QWr2|ksQl4ML>3tQU*V`^~PW35@2UnAAjE4 z-{#{OJ1?;Z2-UJ<*m9Z}Tu(2nb?VUze#Kps;c8be&Ydn?aXIttTu+VxujV(u zQL!O$zNnkL7tjOyeIuNg9~Q1GwBL56*3Tx;jF#RC;(y?5?Zl@RTC+mNs8NvSUg?Jy z=b&D38KZk(B^(`s2tC>+MP4c#1uUwUQjZP?Cnel*4her75Jt6Ke!t-3x4 zcEH*qW&NsS6ni61D9V&e>)odd6gvt|aM2I(UxT_S6Xehd8i@U1==qzN_UP?4oebjeV3DW}4C)!G zq;JWGj<IieBUt z^uSa6bHZ#q_F);pKTLlK&m7gFY=G6oiuEE<|9J zrz>}4FXgu$#r(OGzjdwBR)bA!WdlnV+{)8iJpGpTir+F3xftpf5j}ePpUX;bMG}?K{>nVJ&$Z1s@=h>cbNHUO}bow=(Ua27W$0~-MS5WNtxTQK9q@mqC{iZ$C$B3R+ z81`eQF(9l6DE-jF%P&anUCgu*4o{W@WM2w35Oa@cdq$A{QlM)T|K+*mPDFF6e#@I* z6RBWlxgL4$DqkLH>gJsGS3>%8zFj zZm%<0s8-i-zJ%ISo~T3Q{3z9l_#~iQnbUumNl^=s17PAIN2RTu`CIkrlKYnj<@ePa zt5Y?n-0ex7WjSCNV?tu#n7v4<&n_T1zaSU=26F}5NLDBW=z>BraUt0KV18B{niVSLco3&?;VIjLx+xP8~;&l z0Y&@|5qL$D&7K9aD6mGwU2N-HOLnjPeniA+4$W6O3md;LE$&{eEPJCQS;0WzpYbBZ zL)sgwv828*A#ZyUXt}9N%s2O|GwYr`#!IlA3kJ4ohqqrOP38F-{2PvkR^M{@aGc}l8W7D6BZ;D+BysgnLg$zIUfMo- z(5{tZo7^I35$Qwe+7Dk8Sj6jx^+elMB738W9; zfuf}j8-;t$eD)9RTJ_KbCaV~U*xKFabA*+9PQM@iaYqp^(S_wqfz4}Pn{7%Ve8qdRV@9U4YaHLnBbXorHG7P1QgwflN z%WgW{{4i!L_iH7i`*8S!CjeeHER#4Msg@9FmCj&azX(SgkOJSzN|-vrmi=1ZbZ-&5 zot4E1Y>{+{?AWEO&WQ31yAF2lkDh{cb8(WFF325-;Tkve8K;BIuoCLSF}$f($4*S2 zfFO27S`if3Qy)9GIx4e&S60`YAZLiK*iJV*e~?M5Oh4A8d=hHm567K`B|J$@IlA?n zog1t?e>yLNf_xbN*2DzhtZOZ5pN7NU(SJ6C!Px zbaW>iFQLcGP~S<-^|4lN$I4)GT@DTFPUc*Dx_Xch=(!*!e0>uM5}}t9-V-(R`dYxd z*`<5-%%R_fz3U%Wf?eKK^wvy$RzxiJvrLZ7xc_)F+`xfG_VO<5>8~zwa?)W8qZVg0 z&oRMI?m)Fk!k*1wz{~N@>;ik6sq0ipv&(3nSPwIp10jp#W4n zB-q1~2WuCr`H&NyO4SSO`xsYBqDtWq=VIuN&H7z>iQ6=F|7I*=+~jt`%>?@{ogYch zZ9JS`*<<+ca0J);MEt2LBI&D)?>MD;yF52vb4jNEhQpXJA6sWP*v6a2%Dl($uEJtc z>gcm+Pur|@^s3ua`Tx+u@zCNs5}t4?Wrl@H40zG|S(6+S(#UpR%Mt?3l%?>)YGull z;5+MLz+j|MuWyo<1WUYLUXJyX>-GQ|I&@n08O;F*{K#svEGvkw1Nxq26OR$+ioa$K z!c2Vecg$P;l|Q*zhwrhS6R7Q?J||cH49oC@O3>nk;hgGS zZxtppf8hn?vOl~~31c1ID$$DnLCht@yoXF>o7~Yoi)0SQV&33nteAR!BH4fR^6au7 z@@j`2^;sxNnr)XR>hg1SzzQP=_MdrVK)rZVL4Ro9 zsL}n1-;9+f>mN>Epsg(O19QZ&OX}ec-ih$0w3Y`ZGEAM^$1rD?r<6YQ%I7tzp*v~p zXv6b!<0`#4J!jnMK8T&dkDc@W+ruaDvBL?%m{HwXwS<T z8_TFVA>A2bs8ou4$D4_-#6=P6JvYNL*CFJTNshx5$LQc9fE9K;Ffvg@6auD|1YB=h z`Z_=Q$~}D(ss;%cuDpJ8?Mx%V`ZsZE3W4_B@NGm-M0L&lj2*Avi3C9&=a{v`r}~1G z*m2KC!Y$Qz9>wm!_{|R;D0`~w0E_CteYI+trAqQ-w{ELi#2T(2@2cH>ZCMdiziIUQ zYdsUi_^YX|llS7)cDj|FDTRLE$Ma}U+ZB|}<5lG@cszsZFw$0iKVB@HO7to1Ffa1O z{LaPqXqnhSkmr%CjkNowCt}r|JAuZqZUtiQ@+RK8vrZJbiVY>1iDDAXU`yv4uyF)wuHhPYKRrjna`j})?zS0_#so=XI@nNi@CWC-1eQqxA`7+lg- z*;FW5n(OLtVRAOzqVvRdxs)Zb4E`bW#q zXk@y8t7g`4@&P2@jN;ZMqQJ5=pDu4qcyh_*65HwkIrk;P@6{l1%1pKQX8_ zlAVJuA0lz7B&|lci8+ei>k%u*hyV5Vy6JjZu~VFB$W7}vb+pGYErG!*qdfP&Q#T(g zU>`h0nQVePm_5-pinUiT*EPGwRpHT1&TZx%-d&u#R`bMrPG0-aN1TH@r`D>2lN?B| zWp#g_YkCYH39jB){cK_9fJkhM`KHY?LlezAl+JRh;d1t@#go0-&)ZYq_?<2S_JRs2 z9JuYdB;6}VlxqEZG|R)yNRW31OXDiqpSj7H$Re1zm{h4NmevhD1!V`%n7M<|={g}j zH~MjY&pT4nacgC74}2^cF_6+JKy@DOAVkLv25E`Em|cGY8#Y5PCC)Vr)2O zU|DH}@#0(C7{mMDgNwuY(u1!|=OJl)+zVHOPQo9& z%xrAC>)Nq50_%pSLuhITheu|hY7E_Mdn)dN?-l$|heNeM_XavZCe_k!9lPkHeMrOp z%|7at}@mPfG|F&M|R&84UZb_1uI%P{NtV7>a1;XItQ5y{{KPYUqN+IXNHtUwh zi^td0d!5tK8rn&{>J08ExT&q5FtOA%g{lbS8@4b+_^S2D%Q^AnZy_O_$Cj4G-wcDN zVJy+T5pO2$23&aL=&lEX$Vq3g zoyH^b&Ujj`b77$|xCcrSY_ghwJJkrt9{Sq#9}GgLJ*!Cts}L9I*MF!;}(8kJZ6QgQ3|?xmn}bb92y(nRsf!?fHKFZI3Vom}OPW zeug{n#vMd^n;H; zs%vkm1T>+gbG?#S$BH_Gt;btg+{*aT;(h=o-$Gh*2xH`+1F+2#9#-LyUicRG*}BCY zk{nIX4{M=QBdnj?h&kt%;XF(F{9T{er)PgLXVld-)FV&B%~I#=)l~XBmE~1*yHQ%2 z2{p)Nzek6>G-Tsa?208v+)*5wr(Kmp1jkiW?t-gT6hZ@o+UMZ3q@;G3u9p0dp4a=4 zorR@niQjkb*zX?MQ1GZx&4IYWbB+*0M8t6pgV>Y_W>LejhPL1O|O9%EMJ67Fa&bIEgHwV;+4iwQ+8I~*Q}tNJD-?IDcg~D zE(GFZyVh#+3+hwDD596s=1X8#F+A7PUAQ~a$HF^*FP0*Hv=Pf;G#2=u64(~BHzL;$ zSZb=VvwD?QE`Nxl(e^=Adm6o!{DK%>${_~8bfYk&dqTSadCnvU<<^%GK`-sL&(u08W(McLSa!+ zdL#EpW(pHVZI1z$%RSXbZ{P3kzBM(P+f~liRaPeAX4ZKWPZYNf*6BsJaGE#7MrYan zfV(QBFgVuLi8IU9W4wA+V9YpStw90`RfiJ1WLoVZxTn>dUZ2|!^x^t&o&SVYBKy|E z#A%+P)4y!JLh$d{;kA2$$C^3Q4!m!AY`?WIv(}StG-dex)uV5+MbveuOif5epL4%Y z%Q>l#0yDiAlX09_#b$J=yLN*uw=6DrK_75+lyI@4@ESQ>rpC8psdK|-v7>_My@nr# zH`o_%KpcNa&@pT(lD&AWREX886rJ+EI*X=@xQdc7u%45%9wK#i^3@OW*YX6lg4zfI zANv=C!8uYA)Az%vIjf?^H78RfpNxI#?_q$aBUWmKd;?0=j+KTJ|6bj4tpFsZ8eP>7 zJUO(>OEHxhfzZRtg6TB6xny@~VdZg-i~lzVml(fGfYMr@iasF^sFoa3dN)#W)jcwhVW8ck;9+JGc4!NK!(eEn$<_7B2Z=Y_ zzi8d!jKg*jyvl3k(3y)*yg@9ub~Zcf6+M1yz8v~8K+C1A!E`xdq=sIvB+b2(L$Ax@ zZj{*i_$MA2iw;bm_faT|b>q*a$_4@OwbHB(n{VQJ^DL;_pNk031^IlO0y6v0ssggi zJ)G-YRc8^S7Xbs90&$IzPrXNLsr8iO1aO>OeYIrHujisS9bZL$rSRKng;~^q^cBca z&1A5s#%hEwecOs1VP%Cjc?>qG3Bn}GFZqkryta>5jcgrSS{tt`qHQw6SKC)6RBGin z2;>V)EtTDYXg~Gz+&EoUrb$&^DT{CKp(+!4C4eCW)+*MqjwLSbqt(c1Y06=0~r#yn#Ov3m!&y~*lKPD z4J?p*H_6N@fi`5^ccvpJo~GoQY)mun?eOfcw1@t!nDuOM>;e^(r2w^QXmdJGOb)B21T|1F!nsrRy6iT7#4L zK#WR*GU~oX+o4RC>%-bS{Ta_-x(9kk)W1Wy2;pw}gLK{}Q>w1Zhl&r`TL}^FOrN`E zbF`zn=?LrE$hku@oPMMaQ*NY`8y10Xn6@7of_4ukYDugX%A05lQdpe zQxFPs>nDwxZ+kzFJ^0f0524Hu!c`p9xdT0pGJo`pPB}LlI^e-CKcYhoMqi8q59=|s zyuoUv3>FlexKrn~{N~RWh8j`pzXSD2EjRp$r8aRIx>PEi-X>J2RJhriIW8%clzwUkSQxDG(gZg;)cFn!qkK?E9>XTOcJwjWy@!N$4Gm zM?cj!`en>@Wf@+tS93#8^YW$OP{L4rPKJyM-9K{b%@>wHmoIgqm|7uA(p$2YYwzLR z@3JLl55L2=N9Y~MWQ5zpaA~1a)O}3&R2U;QAaMtM9sbhYhPvr)ad{+h!dqed$Bq)( z!8yImB3)TYejJpSRgk)IBAm;0m9F`7kAnk|*JQ3mKgtly`W#~n568c`;V2ORH)EQ5 za~;~lqc0$qn9FM-+bE`3n$2VJKdyKB=YGR`(GQXGJw5I>(y4zX*$qKbmY@!boT-WO zLi>UvhK^q_esnp^yrJ8|1r*gILcP1JU3&WmUy99_qkgaHImWTz5ZkMI4Xw-9&-Lz5 zT;N|2_4DXAtKSjtEB3WD#IyNR!gU^DBkCyLe8K8vE_&T1r|L!*CEtH(5B@(O%cE8a z35Jl?dH*yn&qk^|r7Y^;kC(xMOJyixMb0~_wf6P+ z@?W9#x%?dhLV((h$7Rjp3Y6N@8}keh8&vpie9jub<3Wus?!;eRxlz~?4GTKr)m+v# zrh3#4aVpzEb@_(GBYr$Tj{I=DIbX}YEFXVrx?Xws zQFlhF^-@#PKhm6yNlw~g^D^ITbomVPy(|;{X2d5Wo{atAhH9B3TETn9q{UujNxs$V zG_($(T{kXugS6kMZ>(Z@e5oD670~DlaIbWjnGbXCVGd@Ht(dnop#(5|bOW1>qrrT~ zW&Y=dK3+Ouo2en9tlPj)_Zw=u1q1703>?llNY!X<)x$DnRiM~Ie9@I*LAJNE5_ zCmj8*5BTSkkA-%%tWNrjp3))ZR6y#rvYL@@Rn!7#M@mT=)u6l15Y7s9I*>v7S9F*l zUq<@PQ6}sBhOf+xVp=&SJQ4+JVEu%d@71#`(jNZKxU7a&m%WUZ$7kGp(7nbC4{Y?r z^ERCXoUpVJb}bEL!aMR?NiSC(OYHZ+y&EW3)crWLfyO2I&luPD*0Q?Ja$L`314B>+ z*a6cHnp?slA&fMT&G-E$bz%^g6Lb+o#z~jyo|e?njZws+ww9riR)j_u0AakUs0$q{ z^d0iWEL6rD9-X_KrP%g8WV~Tt*2)Mqf^u2=5p2XMS%K83>27z6aW8rp$IjNRXShZx z@q>5pen>ZzSB=r(9-`2SaJA%j_(N~D%?q~G28nLDBdjlbh-+ZNK0Zt8S|y9Xi$E`r z1|oMV;aw%!ln24X_;`f&62GiPLGca7UOnNYC(i;6^TAyjmV^hvoipjQl~;#67`}@X z*X|{Vue;fJGOU7tCk8u61(gRP+x_mtjYTBE#qf_x3Ydgv@*%jd6_O!2R;wD`P&E5+ zHX{8z{tCqCKQ(B)DoM+nUB8v4WtWz6xbkp!tzmLD&s0-tl}E0i`K@ez`eu9N8RwZA zg!iiG)5bB6>Rr`ecfJETnV+LGhT{tTTkYs$1nMo3c30ocbR+auf5bpNC)vFnSvX@g zi7{f`%(V(P7%ftVu|Zs|^Na&YII)AMx5Gzimuhh4tnC`vqBnZe0o42iyPPf5&r-2F zvf>f!p|NF?lU!(CcDzXE;(fzb1p%pN3ff-3=L~LHja`KF@&#^ngVUSQy-`_Gc!^DQ zV%a*I9a%KxSZDG zud>w;hrwkJL?9Wrr;^pJh8CMr-CDixfAx24u5^oUKPCjZ0wZjU{6RCsUKO~PMi%1J zzLEUk)&k4)PAP!*yue6SbEaKy8 zC_>NA=XP8NA;y9js0Nur<(uy^kewBQeQ#QQShmrJhL)|o66rrUGW>1yS)X#X$ij^Y z_nqqUiN^P;rnB@=%9BLb=3FAKUz;NWck%0f8(p^pT4Wd-zXd4PHFNq}<`cv`dfFmd zEkdBpyxIb78S;rwISOmnN6eNHI;J3%Ap^4F=;k3vzH|HVZgja8-Xa5X8D1o;)#DfU?iQ3Dwl{-!APStay+W@jL1bdCE2NwH;m(0fJG(v4{E*-mw&r;A!1faJ9e-;QbYhYa!_@+Sa|V@ z>p@J-9t0aEGnAVGsh)dl#79!Z%7uURd1@@V{;Ji+VNMgz4NpyoSGii%jAD05HWxb&=|!HTxy_s3FYxnnN?v^(!27@^rKn`l+hd!YOfX z_?&7_s|1C!j|zqFK;p(@tN{Od6e$3DmLn8z_SZQx8@Cj3paP@g2B}GCcotm`4peZ`xlba z_2i&gjD~|=`&QM4>RYblBrlq7-dp|mB22rOIu;vy+0CPhmURvK5nwyi>+U)?_)VYy zcACd)Xb!&LM9!DR(k?5+{x52rKYYZ!S0X=uj~?rYaq+xw7_+(HWpVyqQImC7Ud#k- zyWVESqsHFF)9IM%KrvAWJPWK}4My-oqbP;DTa4W>D-q%R=M>wAE53a_ar0!^1nA(7 zeCrXlp*ASUz}911OL;|z+c{(R?bvroCb0kvhQW@qjKD{%A|R`<`Cjp>WnTa?fDH3j z7dbMFG^!66=5;@j(OoR9@T@&X0+&*)+<~SBHy7gUF6L zA6zNqnyo#@A5%f(fihB^+zC`;9$;hclIHu7H5K*UBSyCW?jVetkoJgY_CQd79B%8N zz8|m^p`e}y1*N=ly*yH?cQXqc-nby2++dWY-xB}}mO=08%>lL@5(03U@qtu0p8Qos z1kbLvPVRK4H0s4y{EOnJLu_k*a~&)D=0#HKru70S_t+c#`9@GYMdvhG{gL})ZLhp| zL>A$%A6Li}&EJJd(I;`Wc-)A3ZhW)lS7a~h@B%BD$pYNki>(~4fw&hkpW(c%T)QI( z`i@@6$^zxzB>{`K%bdPR`rf2_dXWq+*&pK=Y1}~8%_9Fw`6tr=f%(*x>J}{)5k?;q zR)cQX@_7Hj(DUW!-F_geoqB(Jbbb>9GkurWTWok~yHeO2)?A;HWD{PCh{(|E9=-P* zHYEFjkCWV7gx%#~mcIqRQiX+ZU0OOv&s;6#Qsy(*D_%naZlMeMrAGLy{?OL*i+4FN zp5UBMo8p_fcY5>L_~s6J<1C8Rc4#>f*izc-ZNwf8wzjgVC!uerAio`PE+@Q;39h!& zMV@BF%KUQS3*Ur3t*wmP0oSV|5Ri3){1o-xTF8}437lL?25Q5#t{&h{tci;)bbLRp#YDeHbyL&=d)gBMtnx z&;CRn=jeY7T>;C{hJbVAssvm+s~KKS_0N8JlQ%ia$Tb| z%Z5fRiAZlgQzl`r`=Qc6XPu0U_H#LSo49V|Y*y6_e0Y((-v5bKcN%U>9_2XF%X_J{WH zQL(J{0+CmPxd)5XM94HIjV_+Qz0f?@am^@JU-5$NekjB1tUWrl7mKHL&NzWLklMz! zeldb|&{NgUIWHRbZmAQ$82>q$NsDwd4vN6w?;cJ8r??Ig(xUu-yi%PoqNid7<5+NPax;t;_BA>T`)aIE?6;J7`SEOm7 zmqQs4ItHQV`eJI2_)E>NX|+0mJoQz!S*j+txh;;g@z8`eFRJu!#7+7TmVT2zculFZ zH+$+-^7g==PlVj3e1Qah<2Mbt(;T%&yP=7qo{&eCR7h*OW))+l;oeiN(sj>W{@8qO zuHB_XKdwfgV*mOWvE_kt4;$bDtr%)VXzgb_xN{q+b^KF{M{qv#s9>OL&QU~roVE)1 zo=%)K$ka}Yn1uV#A-1zSo!H#5+yuhr?Gx%>7LXWQONU6rdJbqZ5JMOAXG)sei zGkZZWT31QN7bpvup>G^nPjv`65_>DVsP^DhI^DE@uk02 z1f@DVbDnGvWG)}+I@_fHO-VK{ll@G4a&mHB4)grVbunPI!3uD4sm`9z!;6Enxv}`J z4*GhPOBGOU%*BLI?cyv6v<#qv;=wABP-C1SXBjzWjWclHUc#A0f+SDi9!taCTP?f< zP{6hcCk|z@$-d^czn{W*x1bx>OWehO^rB4BZd|q~xPdEKRHr+z#?uK6L$ahYWlV^}Jv-L@)9DGleGs>_4(}r^)nqrE13Mf}b)jD^mA`@5 zfqwM^1&DSloA+%pZvD{SVsPdljTH!vuf;?sy_F*ym-OEEIL%^0 z*a5OsbMRgNpI(;VTcddN3+TxrChmkse1QqM;DjBhB>g9UyXO@WkOU%6& zp>KE`ekjj|7H|VA;F*Y!hPBt*=_3&nay>ma1j9nkMbh~E=xZy^<#IX$CzAi9hHTd< zjyFGRpuJ>4aEU_t2&&)cHEcWK*~`o4+&B~L?{_J-s7Q@W5Q=VSPR#h(p6Y867mysY zVXwA$vfU5Rn)ycrVJHxPzfiA;AUqK?GMPF6~o6Ok~9J*WtU>lJ1fv4DWl?J@~q?0dt>C zF-@KXQbB@*Z$M+pppDO{Ym|*RNCznJ5cp3aM#aYb>JPG7oX-jujNYUF^BDfuso3g) z$kH9}3GiI{VM9^=O4nO9HxA_PgxWP9_&f+hmNN7u^1jsOv3Olzh>t^N;8P?aNEn61 zG5_&$>v6}TW_hvdMMaqBr_rrw^2Atv$1vnzUW8&i!%CIFZrH)Z^D>NHyPQ<S$v1H;1{xD!Kn=6&A@`ZmkKyGX}aiYdc>*FjDhL4nxzt;@C|j)!h>TROdvGmK}LcOaA*KrST*)P2E1 z=boulB5XN0%O7KNAvq86A*Rnznn_Nxi`*9t*ms&^`Ikk<{02DB7kSk8o)TBVCbX)Q z+kfShF8uOnvl-yObT|V!UcSc1Mr*WmFZ_QT4j+hYaqnHC_z+3*x~PS44J;Xel!ojK z#a55fY+A7^aO>5TI0zbeVO2!VJ<_+WBlZ3L=1%W*R)fny`w+Ls^(T6j<{Rz#7!Gh? zeWp=A4fwtRk->^m;>X9AJ&u~sasIRv_SyYwE2!WAziTCtDWe&;zZs&_O=3JGQ6mXk z(FMvJfv{WcZ%nLo;_GPpu{1XD-_RF=nO5Vh(-|GU(h;lSEjW-K^XZH7VncugZf}KC zaBdHk$sc@ES?C(l?NW!%$2vsJ)Kqs0xHgv8)uRS2qzJ~61gqjke;`>!g|j9E@vv_? zL$znO5Spo=TE^}0%@_~}iMWp~nzmf~DsZPvn+Cit>;cQGt-!70##aZ`qgMv`;qrLB z&H;YVDlEzc9H}WFwpl?My#xgFA=lU~-d7kqYx+cb@b@w$-8(KcKrB}%TnR4GS2)I% ztwFUrHKjoT*98x?@N5aP_Z$s(?ra!Vc|Z(sa<`!;8}5N@yBVsA`V^y#eEfI4`tR}; z5E<83F>LpS(CrI0@-7frVscI}qZ4rro6#o3*o840p!LKSz_W>&Ue;y!gUW~tvRRn4 z)%GQ4gPjg4zBzkENDg+W>s-ftcF}<%HGx}HqG5c1SNC{p-Gt;gp7vzA4%(&5`V#&? zz6^G9Of7wQn4}3-`>1XT3wV$VyQ?xvWh);j2I!svd`%L76IYM$7KR;?eI6|FH6WU;&z@Fu^SR>q^}g(z-nCF#{Z4 ziN~FfOI~Jd4f9aa1LriD>WwfAJrymj zJy-f-2e)Slfz!qM;Gj_E1%8O7JNOP2IZTB^j2$t2>mc~HO|LSfifiH?r;h)fztER@ z*H1pc`vZ9p{^Ld?7Wm6FHDv`-16%8=6x+YyQNT-)Jy^j9cCJOn{ORnbli|yJv14B# zNcvPMO?GNIS7JJx!k>U{j>Nak*)ahh_XSaVkV*ncHC7s5n-IdW*`@p5N?xZji8Z{U znuOd6X-O%oWnqu;mU?yGmd5bBaH?VLImMZPfnh#`3qZ0Pj2k$Xzn!QLa<6wM4qD<= z280l_)$>g*_&`GVl*drQIP`-(l90u5ts$;XkXx3qsV@ohPP)nKHY%X<+!mDE(B7*~ z;$iqW9yrCgP)Ryg!vPaGW^MMlThXWOUu}F_R&;wm@n&|fj|Rt5ELXXj+-TBK{4%_> zU#Tf86o3(Xv&D!H!D~4}JSBohw8mfih2d zAg0Z4MN6jMy!D!t$@3Ob?}Sg5Lo+6iP1N+NbaZA&lb5N1<6@1^0KW3-(n|B9jv=0! zc|>#MBm|UUyo6c`=m|lFQGuhw44OtGK70pJ#zbfyu9`SEzrLawD}L!kh$kmima*UX z*WTzRM-Woz&QM+Z)!b(Da#tD4!XOebYkznGtQ>3_g;#cXlJZYFpfPF&faWU87YNBE zNy>4Ma=2H1YMQvo^=5%EiW^F1rU)^jGzS;SoBTlQay`nJ?#$=WKD`PO6lg;i z+y3TzhQn@RE3E2KGw>4k=@mP0a`HZGkU_h-FnJ75woitknj#VEd5jpW_eiz}E8B)4 zNv`5dp-k435D!2Do%c-Tayg=~uj+K+X7& zqfsEW4Ip(|C_+1TAc1dmWlIyf*yHFr1k~_8b413Ll*PPak_Eq*BRKYa9(u$z+^O{z zj+g@A3jr&7mUQX3lO9w^x6)Gkag75mNI*0Q8ogyoBoi(`8lKhKLe^Re2g1Nr+S3uWJN##rc(^nC`Gv#XiW z!z`OAQQB#XpjD~o0lEt7-L8N2W=!25v|j+jYnWMF`C7+iTnTQ1OYUHp+jPG!9b7iH zVAoKGIPv`l&i)?<0U(yIjgVjVo9up&-Ipmm-OEO@ZyEMkrpx)^J=zSM)M_(#%kJz` zv@ll&Pyt2>dIHY)hRl$Vn9pMEJ>L;#=>=}N4DTIwoWDrQJ!tt-AIglF)$n3F{if48-~)Jb_uG zX3yboT4AU?Y2fN=;T&^y&dYxWK8JP0tfj07P)f5d$S( zdR@(57$2&BDjI6j7d8kg3It{wQK6e%z=%*kO%R5CXKm!6+)DI%ZQk$$+e0GsU3Evb zhj-o^{rgP!H{{+kuiR8HL$T7D!z!6jtj(=hnV=61gUQ4Xv!9C}?cOWMVlvr@x#X$} zmd@(;l123K)U{+X{dO5rNf9#*XP8N4_4rQj`94dW5!*@k7%Po}^7}DE*);!*TYrN8 zTm>Z~qLp}rr(AlWIDC0R?DisnxYp+mI?Aq2b95B-6~oqyd9wPG(um$_rNW?urrFVh zE(f-Nxc)%T6ky00wlgOfb1{D~ECeqyCWlm{S2(&XaYw7S*v0ozJ=wSe^tYpiL9oB; zb4h!IfrtKuOOJC&jrvPaqVKpIk4YMdy0|G=6(@r{L+Lm9;>)7m<#_UmvHgi%CZsg= zHnRtRC%rkA%UJNtNQM&BKH|g(=k%_}pL!|39sj4h;Ec%5WG8JRXD4?KV=UC4vr6TO z;218RT0qLgM~F{wv{g?(mK&FkSuQQkbQ=~dBO?!O5QD55=JNG2N z#7hw;qZ2h|IAmU*zLm4oa!^C`DZ4Igt+n|55Loof;vLm+_8^WmSojJ@ox*v;M#f%; zoZUb;MbeYq_KJHYjg$aOGI%MwdDn~R=dMLbbq-d1!j482S<<+&nmMvwD4RJ|QzQD6 zE%x)zRL)M$%Td4^R-T8~r2s#K!-=oq+DQ%sgrTqs5L(0W21nsn`Su=E%{vt~=A8rG z&Sw5$^nej5|9uCwy&8qwuAp75xgmn^68grwV>MW;V%&yv}~{UcG{9wCbwL@4?oAiTAEp`Lu8 zW7|(V=d`v(Ww9mx(rHPC9?f1Om!}d+>LP~g?bv^g*nNl;)F85@GRKq%oWLM5I%nJh z$=aX_x1Zz0dLwVxEmmZXZ&%ApSkcxvW53F(P0-hw6T>P*xEXamDE^LY6eA7^W^KRJ zgCbHk!3@|zTvQh9ds0vj$vex9J$|fQdF;{hk^5N`J7B`q7hMt9Eo-XmU~t9SJSoK< z z#GqqXCMzx092YUToESq}aO=OxzN^xXC|P701{qRzSyH=4A&vSUUzC$D3jdIZ?Z+1BitN~^irc?^rPbKnLO z%dbY1|Jb2A3sfn7-yJY@={NJDkHl>eE?6pRp=yq&&_8=%g zp?fo!Vj%rhd$b%V<&^;SGFf(hDnevM|Aul)*Cu@QPM)$%oumrCWraI(^>54yxI|>n z?W2v1J5O$CU1MK|X^I$4W6;iFLrSWn3-X&kSS)+9FTnMjubQ4lOA)4)( z>2L`Sof@i4#f296CD{j-8Iro3)XhWLbAeLjWP!A=yFUE3IV+Rd*-mE@1Du|GX9UX~cD2qRD1Dc~o{m##pe1Gof99SSRr8tt1Ts%fi#97(me@ zl7Gsd*3z@00d~xYwL*LNy_T%;GS*v6DySmw8L@cQ%a`=6$Aonvb}?yNGl9g5@asYx z2e^9=t?XVn?x2~W3@BAyo&43i{kNpka5lryZf;@1#i+@Lwb7bLE;Yps zl3W`fkXoC&_j1g4f<1&c2v#wUFlFznX}-M+D9pG^n= zc{D3`4JDw2%f0P@C5-5KdVCvYG^54m4l($q^rz~Y#fVT0W&%e2kU$SEw%(L`jLDPw z@fLd7PqeEMm#=4C(Jte}&Jq{veWSv}1;5{w<->~x|2~fIBEFVq~# zX4N5iMxQXY=YBhg3t)EA)z{KNoJr6KtcL;JnaO`nX~H-DE)gwt(Z;{}%vZa>3r00Z zZgo&DH5|lviBz!Y848wGTlJu0+Y&^C@deXKd9!ROnIj2xbDPRx-O&|K&XGz5|9SG0 zox;K664qjRjhgtY3Aui~do~xgO;7HgAe@q=QiAJUg|bZ0q=4s&B(W(7dFbFC#J!~? z(!Y!SM5oa}rn|Z#F6aHZ^iRMqP4A!z_}5R)wAnnELD`gdMOtAF2(gW}`9J_!)rVr+ zT<9AEDCcB2Saa<5+$YV2N(zyhI771E>Y=jTFm2-$on=z2EK$F}uy zk;$<`jmHsYuy6t;ofe%g4n4-Pc|D#Bgeeu0D71!Fp2$?c11R+HljjQ#TZ>@)cQ8d) zh@5&ncGlAbrT!>~206~P#SeWknIjePGV}^safVlMKwR8|K|H`RBsCCyoj%MKEeY$* z(-gxX;czYf>W)-vUIcV-6H!JTkiGxJIG^|wNx?jQP~;sk1- zB(2D-iH-fmxxM49@6`(@&g8+kC`K0i>uaN{6x1hn!)cl>?}vv8SPuYmvsG(WiwzL03_ewIvatOnb8TPk;`t&|^-RtImG@o^ zb`V32*Ms|5lzcROLa;;?9X$PXip{WhJNZ8u_H8)+ZS{9~H=k+CIxXsGF&FFB;X%{d z6(SK{1U84>#nZO+L4v?KCVoD-Zl^6w;B1NOZ)N; zbqy5c=vyD~MX=)==kPTzqdo={Y#eBGl0k}Thy_vf)Gjma#q%ZaC`J6HB#1P@?yRwA z>Cif#9FQqgUp!pcl`Z0yAmJk?HQi`a^b+kGhh^WX`$1&I-TFt7zpCxUT&>E0*Z8v< zH;k<_@Vqs#_SDsL?z{{H7aC9iC;AoTYh6X68xxYm0ZliznIX9@FvICb%ovUW;dpLT zFDSRT^BfUbF|qx%&|B+;9?L?fu)={3VzO@pDZMO08U%lC`4nQ`fN3xK>QVuoAWy*Y zSrK1VoLl)e{E~}<4nIhdHl0TvyMgwhT-&Z026o~?5X8Nb{mAB{)a2*Tz~h;MRH$t$ z{wN!Xqm!o2E?ITRd6|t6w6`No^!*5c_yeJrQRk@09x*c{K4urbYHY%6&Cw@PY8@fr zELHH9s{%b;XMPSc(g%cMBoWN|VcR`6)Oyie~vBG%o_YOA5t=tUp{xos9qQbi} z+{TMgj?}CsAWTzgq~vVZp_YN7I+b!@HvO`BTaDm+MZDBxm!4dHbKWZveZ1(%z6F@gp_fF=a+ zRT6i0I4zTc>g(gEooc2?F^6KGsEX$*&*&%cZ4nI`FstYZQZ+u4%W_BtchbU|iRbu* z8qhtc)=#HQ9vWOl)`5!4Ec-NR~%S_4neI$|VJZM1{hgc)vyOTqvyjMmk(2&(aV z;0Xc_oJQ1n$i{j>&0_z2zq0stJw2DAix??+80?_H{k*XSF$E#xE}g$?$s(->o}W%K z5*nkb`JeM0mi4=Io2J8oLw> z@N=cnH6l>jo<^2meLlEdq*#w?EERjI#ghfe_qdveJ*y#&))aP~EC?QwHbJkeAwkZR zM!-ge$jG_E)QjT8#ESYl#~XCCUc! z>k4>i?fEEik2N$+1cZ=xHQrmj`vM{(ufgH+^v-Vid4&vCG@Z^KsM3HKGb0MR$P8`<47^#g(jX-p{^7wICyF9)OUG0wF;sVwK@fVD?v zrVu#GB1QCkM0`1&42L^Xx%bWR#07O_g>$+(kU$)?1IW~jr_pO3whj31ZO^8uE~)&6 z4>N^*x5_&0+g~dHpdj;>4|Cc|4Gg{fnv-Ny z+GtjT=_??kOB#XPPC1x%yuy!A^}+B(qk6Wgi$vz}e>Y%`W%`V(K;hgR>F!d6J&3Z(G` z#b9p^tsP{ZqJW9AHQqY6`w8^e-FhHZs`GGWc33VwRpbDYEttRe`f$mM$0`8EtVhAj zMk0JY%(Wf$cnsa&bG#I(N8(%wQX@A7X|!bIDJGFDXZ6!l?8vNd02$<5a%4#`nkA{v?V4GOVCee0B|`9VQw5le$utnA1y zOvohgO?9|))HA_X#~%|x6}|u`2=M7b{l3YwJ<@j>)S7HL$;i^7(x*XYd6Tw{!e_8ODsfAh0u{q;ZyBZn(O^?3JQZA6C7PTSbV1YGixY z3^LkN7r3@+i{RmKSpuuIf}dViNZZQB`9k5` zty^HcyHV9v1F<79X_dRM*%PVcJ=BQ)v@X9oO?}$C-z0_2!&3VxNH!US>xl zc&h~sCm{C>@uZ$pX;qT+yl*5Ke_cLrFV|@R*JWk|!}}AXzU-Nro-$dUFvyVaiP~x{ z0tU^S#OtU)ihG0lCg0N3PMspWXDPNdZ(ZZn^X5kH+Vg;NjYn$_=ifJ{|54ETIe+8z zPdc-2Be<_ZZtJGRlMxQ3GG>GpBAuJUb!v>s#P44C%Hl`UHk1BY?&<<;W>@f|7^fDj z6wW3X&+5?G(k(C5H1LJKXCWSH2xPgK-W2TlnvXGiUP$7JMk+RB#^>i!^MVSc?eoYm z?@X?QYbbBJ6SPq$)pO2<|blk;w#v zbj_n551|Qr+-J|Y-eb)tP9DRjzKZb1o8T!x4u2 z+z!#O4pl@#d?AtWtO5Qs?`ZEdp6+cv*25R#hXY`iSKOx7Z^MAyBLicri*y-|m`PJ*X3VLFz@MQEq1T2Ta zGU>o^yrM;6y7#FvGcmvkrbP--4(zTcjZNO;Oum0f2smL5)CISXnamE!+&z92`2O|b zgq9al z88EZy72Yf1B>VQ*%WG)M!RI`1tMc+zz+z4OKdJq^wo#g(r#oUUxE-j9Bd@a_fhXdRYbQN_=@SE5Ur2Hi-305Lv)tk%uq z9!rNXR6oo!HzJE4dDi6)@44&gL_*<8ISj7H)wlHsyIS^x1Uc?jPApBx^TW8R{3+17 zzh&DyAC!0sU04SNJXv;;f?MT_q}x?Lm>!ykm*BgLq=rJ`JAZ=j$jsWWo@Xz z7q1#bN(ub|^(tT00E1iSV}~%MDLufQAJDuW=p`DRILyRZa&hSH=Qm z{J?prGFBrYUKXGlPI#}XuUVGCs%Pzn4403;YC^wlt{smC3V0uIj`%r3sLd#06ZEu7 zTNLX5O;IeNinw{q+>t@R(;9CgN+j;mYt_(5>RQ>af`ln1gjq{g(RV}v^GiOB;xrf= z!x;S_5o8Y7;Eo^CqdZW`^qmhx&Wu!M`yn876K%7Zm3SEQbio4G{W>LhY(|_0m#_v@ zsm{1w(#Vq+ipDr)J*PHTng?7Fs@8dryDC9cO{z04r}5HWB&;dFsTg9c2;MPkq@$f^5?@Idmhjm)(YnAKH2+x;6AUm#O7By+$c~E+_74Rv;oX!=4%^8dpnJ8lNtJ2^PFnFm~RganQ4?di_#fPW>&A3mT3 z?QQ?Na5fpP{?aP~;VTGlVb<~Lz5Hq~f#<1ChXc>o_nMEKMS#}St7BhDens4F>=YZ_ zumT;PJ&KxChABQj;N1*)@kxwaypu#wVA_|pD6hZpzFWQmcQs+1Z%o)h$IC(I3|<(5 z!@nC`tIoZiRFBDT7{1bpcEGKUWp(t~1vnvGea>JS0~Jru4a_l}#WZvGoqzwKnUvc5K_WZEMch zww=t_wr$(?jBVSt?c~e5&e{9hd#&>iMr&2isJnW4V#!z&Ec)b=JKT4qkp$h zTj+t>O-0<^7Rrb+vM~a>^N+}D58>J=k2C7;w{13T=SXRrE*cDF4S5mCg!k*)4Y+WY zg6y6=)X`-phf{Hz?X<6rXqmFqPNn^7?RLN{R>uJ&7hq$TAlt~;F6W_t$%>BcK$Bz+a~g=;*Z*1iAcP)a+?LMXRR=pgS3wa-FT(DMmP&CIoV;HPmWRxm7JAa@blr*P&IM?A)*EYHvC%u?gfOV+a8DnyH10|4GUhK+l|@!`@ES%Ddz)(2rDny; zfS5v&k(4F9kLC#F$aVdXDXV(t$^b%=;TgM0Jb3i&^3&yvGU7X+)ps;l6+WhrAtTJg zwa{9Ld*sQn6XOiJnmZr+q(Vu$GQSo0J=yITR@n#S|Rl}2HYHF2u`1d-ca3KMLK_ojLOYto>3jGuitdp7^B@M zr#~j+F4Fjsx9%gJblW}i4n(o_3l>Swri}PZ&g8I&4to9M1qU;@aYP^bUqpJY*gQwDf>F|9lPMqq94n6|Q0RWP#wm@hx+B1lG~u22|>%4k9U~ zJGW{nM$9DQvvq_@tzw_A8Ip%S`{LyMhYnmr%B!4&~1W zCxLqhsH_s5rH_WR$?yP2==0?- z-3rZPbnmoy#&Qt^dFzpi&(cVaL_&FyJGV-+&9sVY9JbM12^=aCJAc6cbVqaG1Gikq zUagg}55)|%|JTQpo;bT!nnuI`PO(Ie4~|}N=JMx7XE*!9tHm^u`9rL&-Ju068n)el8Ek_=x`TO> zrjV*g|Kgti0J~h}bPcQwrF zP1hbQz2RrA14%;Ufv;gIeKBTC0&k?vM9P^Eui26urfG^3#X@%;{$Z5gs)pU%^ITe~ z?sXe~Q%22V^sAezp{~@h{}$(+z=xg{6;M1SV6A|*A9WFHgyLSuPb_GwK=ZG6!nNZUG zv3PzL6_EIsk5>R)zY}g@Qe<$gcs5sC<<%CI@*J>k<`K&Tkd?M-?vXH&Nj& z9t%*B+~s2JrVU=l7^q0TF_1foF8-MO>7FYHjUphl7M=)_oD!#MUl=ez&vR8hBs7;( z%&_!ntxl0CBe1UAF&s-bl>_I|b{fciAgP<(b&z`qFSIxIkmR$?^F5&iu)TmpUzwh- z9YE)(dz$3s{P~b<-1|CD3>7JRy$kvmCDReyY^1pkh>FpcwyHS$Yipj7yj$S z;_}Kttr9fofj~W;v#P!a;Pq(|CQiHK-K6T;M~qFcBOj2|EC6S1h7yG)SnO%Q^eU_7 zpwuHNO->Cm2JN#q40NRm5nhj71a5@EgIy%3Z#;{V`$Fuah1Hs19lAW2$x(xT1O4b3 zUX1lRL6n3NR%kh?9=bfQN?+-d$ju{bZYE~!n%=6^Dlb|0pLb>*+$CmBQa5by8t@lc z3C(Od3ACN}TpH5irTO^G!L^CgB-=I-e4}D^H-g|67~fjKL!G#;EQ zavctX_=vc{wPg0Oow2y7WcEECshd4N=%A9B75I1qv>EJkq9`_!P=SnPetvso9KYU( zw_d$ZgtD>nYF(@B?za0}tD--a-FBO%U4F_hMxIL6D%zL{Xt@0Dq&)Yz?$&0Oum7oq{yfQ@Ddo`; z^hZ&@e~1TXXefk~3f30oVb_G#nBqx*yARHf0sC1z<9Nv|u7cFYXTK{2G|oFTEX(Us z2OVj!UfZaJY|*12@($1s9!*tc;;KbJk_}9wemy)wQ_J<|u8-|x`HD5-hC^kuJ&{v| z^8|bGMV9%a&*)$|Bok`R=O8;hNT)JE^f}zrPj1)2|Na=Cp^{=vD{^ztf%)39vsw-s z3t2v;R=x6L3h`^SfTp!m9%wH>5-2;N=ea*5@MLn%h#a~j3-fuW?U@&@v5ax6ltSnZ z|69(cx>uSF_v7wCnsW9oH8yf`U-!xAZeO;+?ceusL;2k_S$0k&S@2dOj}V_A8olV-VNRh#OF_zx(lTs)X^Zm@8fR@WBU`AMEF8f$Pq%a~;Vlhq_UdxLlk6(Xrz+ zneYm@nvv>Jt&Q{M-vCPxTogT^>N>(>6cbeQf0Sszegac+<5ga=D$fktvN|`Uje!5H zjplYBaJ1<;;`a74g9T#zmXX?w_}dMUu*H9SpVIIFduAOn2ENwT4tXt@S}CqEP$=r> zx*{9+cfmuIE>j1h0h+EQg8G=T=cNfB)rm=dqntpt=({bJBKWEDm+>Ai05{CAubN}- z4G=1W7!3h%a)y*bBsIhgBLSOemZneUF$>P3Rj!gUb~ljbtPyEbZCb*Th`B;nU0l%) zU;=WI$e90Z@qsX`^^}+VZ-oJ95U4Li;zrwJ?Gknsnulvmvcmk{>D&sfmLW+X)czYb zM^Q``uNWjzi+sGPe%6N>wti3+pX__f(!h9&QBz7{=-K07@*ICIr9

cs%_(^sab*Dx=;c^t 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 ITyVYjKLY8t<8 literal 0 HcmV?d00001 diff --git a/images/waves-400px.png b/images/waves-400px.png new file mode 100644 index 0000000000000000000000000000000000000000..8d2619a19b9a7db47378ba757e32c007120f548f GIT binary patch literal 166130 zcmd?RgHyF0Ylk9%+L{d}Ll;n{ud zNlrGIoJnRUGw(?@Qc*z?0Tve)1Ox;@T1reA1O$u&1Ozkz0QFJQiASUJkw91o%L{{m ze2a&BF@pTKCpM8%mIndxq5uI22mt~4{ZSNf1Onp91Ojqm2m->B0Rn>MnA4`j_whxD znWnV4ygUfaM;-tI35p5={*eRyc!PlAg8W78BL^Y_iuYe$8IbrJx>z~b z6aPim$k@Tvg&zp~tE2y3|2(IQmHGdAvj6;ttq%qn|N4fJnSqJ%KiVHn`Ti>9`Q&V6 z_R;fS^aYst{;v7|Df`n8ALC!0|DT8Xhtt1HKUfuj?kMEeRy%s>_Ct+!$=51~|9)a!d()U3t|YV)ptDYfROe8 zn85x{d`Q4*kd-sas!>EvlNs6Gv+h|oYQN)I*}F)_PJ9+u>7tdDvBeFEoWv3tC1Uxf z%}3LoDUkqx95+Bt%dZQ4axx2>T}Q%n)|+SP2hG32X{*DRTxu=` zz@O_ZL2Ee+846iJ*zF=Cg_98JA6xEFQE(i83VQtCq!_lNNPQoRm{W!$k=eq}UYVnu zu;ZKP~0NEb;-^j0qfrP5V8ObO?1~NA77=PhS)Y9|R zv0qd0!CFcGMoeO2Bv0M*w&ghE@Z=j--!mX=TyzZ)i>v`5VQh+wb(UN@9FQhKX3(~h zbUIp(FMO!Qp`V5(-t8K3sUL2K{-yg;tjQ*G2?W9Ze^OQF3NZpy(O~|8Ep8-JrLnrM zC%dN(ix()mt&R)5!iuP#a`_v9lymQ~LO3!efqckqohu?7@+Cj_g<^UtB0{`D@TD(xqzRMleg5-;`(%@QOX+4kyfp2pxGWD;CDaq?C{mVum6q zt!5a~0EpSffU1=nr#ukv}YhoB*OAy~{Qj?K~r9gkuupb`?uvp5K>i~*f8gQv7)54I3=1JaL3>O%quFiKK7bXO!A*AIUqGG22h7_N|1o%bh5 zz^<}-JwefMsBd%e2P2cfoTRiwS7WC+gpfq%Y$?L;92;Z8x8;`R5LGdXmUq8;;{W$Y zUdKriu*aNL5aMB}|Db>QmOjniT`c`freRH4+`k>6x)RRDvZoz2fXYPQc3oY4?ol^(e!p3!X9jG)-cNM`Ct)ike8X z0+5dUiMgT;+bWt+SBka|vx)3+*;~`?QIsVUj5Jd+kq3NyQRiQ^@A4ui(-gN&{_B%4 z2m}yWUBT?CL@SoqI?B`Fd@%{hl$aCO!DK-^@q0vn*$4p>rzJr(Ako4MMp@ke^(ASB z6SqMiFkv5|u$4XTw5$1szf-_hE+__K7`9EDtYA41!rUf4JaTd|VSD$#j_jj^+EU5% zI7Pt>*XAA*haYVxXjd>Yok;84r8F&qaD=;sp!mLh;}_BL+Z?)1*PHYm z#geDPx!T>V{uC~bf+MEzV-LtzInpRhmlDC*IX$&zVcftvdtk%-ukz6v8 zbG}ne{T0X1RC!aAU}DGD`(v+l3x9JQ5z-s^_6Q_1P+cMs$MhN@EO<1B)h>4y;KCNI zJM1~-bdD89cmFO~mMYfMT5s=G!EC)yeqPhj3GUEoV@biGr+9_rCA$jLD0? zsJo!xl7ppb!vxjJCS1F7{m`?%jDcBI!tqub5`r@ic>zn5NAFz#8=p9(QooYxbJkyS znENU_pICr=Wvmw34x1T9E0}J@)p=R7@xfD7K8Qj7zcTs+3rKL2AZuccvry9FJ{-cA zynPiytDBNKZ_(Ex;SZ{ZoEavo+fq3%MgC!4bOV96%EMc*_4SADq18|64knMe^YIQV zkIXQY)Rqd(c?MTT&)bzWJLRngdj3|Ve|hNvoGC*@I7U>`m|l~!%fZ|XkPY_G9%P4; zfM1zzj}e)LzJN!xH1_h$uWc+#&&q8FY=&D>Yz{R?tk0qNVo(Z}N*}iNqS?QWw3CRZ zRLbQCs6+k6g9W{;fAjEl3#ZqybqoJ8@Y(bqJ+xGXQqaLV_^WTkK`6I0Wy4Hu-*sl{ zj!RVP(0IvS;V2O$Dfb)1ewMIUGA&9bfshFQUV0=P8RQ$1YLCB!OlNltm%EoQPevY| zjq35)wA#MH<@fe8)^E#tdFa#0{|uaAt!O|s6+&InUV6r!jPgYRMk*)54}1g>Lr-CI zF0LHJ@9sl(d-;WXo`rNRSp~(Qh0;Dz6W!BXH5~pUVhxMD`9g9B4Hc;S2#|mRmeC)L z!)z2B-V?=pcz=}Xk_z$_8^f{j7E*r8FH6&3o!E~a>I&*yys+o+m4;O%tis&}S{59Q zu1Rpmkf!t5NLrH(&?TYf$z~L#nSpRN({|P0mOtmyZ{qwj2|e~9Ldv_!nRC-pKq4wV z;=Qq72bm*t-qfh~c^L$VBtg_PS*tt=h7wTqEJ2_-(9!aDqzO5#X^k}DpIXlKE}Prc z4mar8SvNXuPt)i*R-@1#P5xE;ZpdI4XUhVK$-5q-cT{cCC%us)teG)`hpo=mEQK4AsRzXUPG1b?upUR?4+a%IP=mIps*xomsDx~qX9*)~tgYf- zmJ=>az$xVXhueEy)?+yH9+x*7&7vna?izpT|1m?lFaXFf6{u=OB_%S)dgb=Xv`w-` zf!y61>Jms}u=wBUJ}zUU@D(Ez9q(9;obghWFF$*e{buDjchp7+*_-2>@ zLX!=R8{m2mWm*5x5NG^=Ri~kx^@Y^LU#y+4|H5H7h4r)FywP*dnBMUS2p( zOg^_dvjL+Q;{%&%Mu_iR^D8Pm4XY@Zf#E@5b@jWP?Zblu$fn?*i9;0c85dSjG#=`N z`&!L5472*$S~h-3>PeW&1JaG6-ed7pk>qA$;ll3192lW zId|?mv`PjhXZw`>pY3}_3~@p$@6S`H#r%^G5$4_x4D2&53n*G3Nqw=Ih~RF4IhqVn zNA!+7QZrZNa5wMq1A}|=$aH#9Vefs??RmlR1xkOthey0ej1Sr$J-kT)Q7Nyw%s?AF zE=@x_S=o9(9e1FA$k~`I5*T2496`2|M(=GN_JU#xCn0{WtMZ(CQKyrkEiO zCQ!*YH9@|WJ*pNREE>1NIXi1DXvbJ1B^|Dz8va)%r%~Sk6o}J*570#eW{6W?Sg*4T z-jY+|;)etO6gNDaLc#@l8p}wFU$B#6I+%IcHnlEFq2RyT&TwoYv7a-OIvy5>)n0>$ zm7=evSU@M}5>t;_e9pWVcJT$`f(!j|L0mX+BvNdlbD#eH^E;u~#L z7x~)14qaev0EpQGhWsykVQJ~HeV1Dg$@vBUmirTNqhWl<{;0hNjkR5cKVD8y{i8fnhrdb-aC6#{yz0e%h%u9n`I zueFXgya(tB8OA_nVjHkhu59O@bH4Mhp0(3@-|vpejp*^GrLnsN+7eo84jw`)Xps_4 zTbX|B^&zE#M^APUOA**S@L$_faO4LkApLQ#n7|BT0#1Q0?GlzO7`cr%quKFpx}M-t z6F8YE7}(@Z-wdA2A}yJ^Yy>Z0e99jy`6ldxQhe^80rG6O4K$OI>>Z)jDy z0v6d?(AM_F0OEIRA16qwPne86BB&A$JUlemSH9u<{Uc@b69I!B02H(J+_soM!78Rp z_2<{Nhn7=ZIa?6e|9omg9c0C#trwrCAejgcE+T%XTVX0EM>OT#RMXHGuq?yXlNhTJ z>0O882zyKW_%+(~Bk*8~jajK(w8Dz#0;M2i*B^FZnX_=tpG&n=P1RD%oy3pF-&dyZ zpBd`#o$^p%5uB91u1a*uCVb(*rL47E_&$VDsw=IghPw_vDUXiHL-FD`%1L8Ypi(S= zyc2Y?-y86@%p^Ge>$o>HthaXQC*$3Txx9O54#JQSNZ) zr^FyLn_8wIjktdGko2Z__z8{X7al1OYrFB^OzDUUrP5m3FLp$frhdxo7h7vZ!3?D? zUo8>lFd=Ch1!}#w_1z@0q$(x-7wL_Mv-gq^92PQL|-dQ zh!JVStg$q@okG~|#_KfGAy^xNBc~$jipssHOEJeruPIkn`xm}3S1}CYB^{nGbk_}` zFD@Ll`u?^o38!Xc{9YwD|8UWpJ)RN@=Y5>!AF;aveWENlf-x0Xqv;+%8{;S7Ph1e( z5r7VgkkA}Ljt@65Sg&hKj}l83QEy1+k#qGk^jW4wpeLZvMQ?sPWsdFMS=i|X2dBtn zeWgn&MbN@{mmhTFKX-l|xo500q}LHAF>J`sAukgvX-7NJvF0oh2eEh1lw?L)G|kY& zL~@T+b-AWa_jJSPaN_|7Ar<}^Q>sg2CtxNaRWcz|A_8okP6jqe)`Ic6_1DAk+$_Tn zpCRck$FPW|CLRxQqgDV^-?H%8>bcFLs*S)eme|=2+f32b+#e$}tW7mjJ+5ZLt#xt@ zBQo6&u5egkE5$=hg{P2AW%yy!h{483AQj`Fy^3iw@cGU-%hy~0IWSXtYs#x6rXLC3 zu&=_pTPtXN(9y9-kLU9)_(9_^rci6cWuJutP@A*sfW(3+yI*i_twCFL^N-GsLfS9- z4S_Rlr`Sa11Ao@p0FTbwe)cq zh|rMBXI<{@3eV2dC zfd_4f5f&2bG1t^+3UYXT=Es9*6Xj=d^CiKUq+|B);`kwUHjdn`9N*oAM2pv1?{rx! z3PB4itaQ%DW=E~pBXs8mp01DgdRhy-oCNb3WWFsKzyq0Pn&|5#dhStYUoy4A?8sxp zEDnJQp^}QECyW^4CX1d(A=UiE?9@qH1h2n!;$12dt!=24BO}lfQN;@=_@5=*%){IO z1LC`mDf*CgweUCnZDI^0NBTm>JHU-)&{?(!_*`m?;j7?|9x#(0Fz_+ZGLZ{*%ESmQ zg+`RULsI-_!0YC4IMGs#WlHmpg}IcbG+h^K0fPtV>4^}Vn6^X(nDnjxgO!Z(u}qGT zCReubZ}Jh?cylL#6D`7sh>|1KF|HI}D^!;vJ+BR1KIA@~s)gh@bH@(Po&9hW3E!!N zG7uKUSmP)ILaW$pVsA^mD!X)3C1n0cu0}(K94^GUehwl*Ikk*8QS!s}T6(r1@@{eF zo?Q~-!96{>JD)riIrLLfVfYpVmg~ZTQ7q{mnR$#4`^~K(2TW6HC~yydx8*_;;C<^; z$TYc3%2L2+5AQDjC|OeyaPcTxKY+aKM5i-Ank(j{-hXn(447_bg6M=xl?#^Bl+AV9 zT!~_paNoK8$^pzu*)1!O7#Tt;pv6r+%lf%^u=pkEF4YJ%YNEvXnd#dyP0kDGAA4&f z07OeI>Na%yhl{qr<-s>)OLK`4e^?qjxDg&(O-mP=;DFtD>$Vn?4}6To2;4nYNfWMJ zM*GP1IElQgL#fzSQysmKTJP$&Q6GDa(4%y39zXbOTI^CPGbuWJ0tV^g#v)*In$N}G z^x>N%+f2&>EC~Zc34pXd$43^Kz8~q9{THsSrh6zfW9X zgC!ZAR143VOo_ucRKO=^Lvf`nZ1BB7i-3g0@t6_BHA^;hsTdC=xm`Um{vXooHR0iQ zzdB8ZYaVOnePtgrbu^EF!uh{P#;J6}V)~9lN}oM-yr(~a#*wQ(Kw}_3E@<^nN8Zdf z3o|W&Y5XEtCo~r}6~z?gM#-JXn1X4nPdzt=tG5W-YXXLSckX}`^77qAc-I`6j(TX* zLXvS2Pj2T6a(cG(j*cg)<7AEE=jFgNTLLefv#|`K-#9ELWIU^z!cPyuS)}>ZRJdQ) z13y`sR7qF%#fUz;5 zdn1vSArX*tEMvf(WTxeS7#N@l^0o0BfvKG*5my&^=q<(3lz#%1XN5cClWjv803KKY zyIA8oz$v#h$2c%RL4dGf@yA`K`Wxm$qU_p|a@fv4oj&F`Oc)HA1A0$;(| z4lYoWj8S0A^B~MCuo9)nf`h?!<~F;(M1ql*5iN%3Wx6cU5@hf+yY{Gv&hHy@h7#ur z1VN4`5w6=NuEx<$v2MvWZ@uh%brHA5ufLgjTGtbCvGvZjDyms{!u<+Gx#&VFRr*8r zLHJY~1pF5i1RzVJ05j)Dn$G$@p)QXe6ZmuayBRCKVB1g(r;l5;DuP^lCH^Y`vo z+HpKwA9HPlFS3=Je4rr*hgs9^sQX%h&rrUlpd#Q(b{6;+uj_8>T1fGNK7r7%C?G5E zE10+wUsC$MiIQaMYeWro)wx{@4G?1VzTEhB5X6W0KleNpRkK8B2O%$$KcWt*LHmwu zRYl0*lPrIu3RV5t54>g6o7WGXDy!L9nys>$xGSp*@c4cmOqa=7KdH8u!8WewwVC^E zT1kl)+^h@g4}%Qi0hpU%mCgp~apo73yNQ(hi)V_C_$atEZMhhT%G4o`$P1rW6C&pc zzrgf=ElJ%!><19%&Z>xu$c5HnE@I%;W|6sm#4ahw}`fb6Jx!LOS4y*FU zCg+5#TatW~2sdY?Hz9l5_-aV^UtjTNFY&wzE1Zj4W@bO&8Y4U56f`zA1S{MuNLR{! z_gj?#wp`06a4@S^vvC0%);ck+I^f0Ljem{CYi}$2Ww%MF@nR$Z^p=DQj4}%1!??qGPR0L+8xQVj* zq2r?%oFZc0d}|6~$oOIaD|A={=Ue8m7LDiuBHyjOVl{3^XHIr=v(eh({Oc3xNNq9c z%X{9W>rPbk2{1oVs#$XP7*!w;)C-9dabSDP;D=`9-tnT}$Lp3m zGLVOoB%={anZDLY+>-mkNBT_miKw;IpZ2dCet?zsR|y_bf4tMG%=+SZr)A`-QD7=CjOO zjNM{2X)2Z+$e^oB8m`e>eZN4U5;K9-f?!~xvm#Wv2~m!}EB8Q3qFh+n)Oo0t*)w*2 zU)PkS8lniokSs6n$pFXUHza#(T#ig%RIhnFI~pKOExygH`CHc98hc4`$I363n+COu zC~d0l_Zf?(5x_Tib5L-!AAa7|`h#-Vf(CkN>hKXvMtO1)!BUUg)y?Ffin5_f!G!(> zSXx`N$)_Rk466e5#VzNYXIia=yH=j*5Wi%7B&hN@60Py<;tAYrVzg z#ZU6NLbQ6TI>`o}b(&r;_n~*+As>64+(yS%?!1IpkQdFI9aSQPD*rUhbovCIxs3HZ zLNKqeM;;`ccraay`l>Yo0#A!-CgO)tc>3;wDskUGOw`Qs+NZ~p83y%JtPA^+=h)Zn zr|$v7*tPD>CShzvEg1?QTLrRWBh%+5KC+9MBV`#* zeL*|U>3fmK#8nBk|4Hn^n<;v*qp^uy+WH&eNL!9%HZsml_VyWJ?n}@99cEF?MH&hB zWP(g5fnJETiQr8$TJ$T&l1~>Ges!BtcUzo_=EHjxp(BQ-lo#y9iCx__NQa_4+~cPrmg&27muX&Uyg} zp`ye-cp(z>i>gq<$v5tW0q@u}!CS%EP-(aTgC@x}j}F_BU*9WUXe$X9PWaSZb$-a8 zLbgdtQEkH2@4do@5;BUPQwXkcGoUW?YJxG_v;F4iEA{7m&S1Xf(@Y(~G@4$u0lfxD z3j1~KCz94<`f9%(FlQCYgBfv;=coR_+g=pYrJ`HyYXT<&DXUy2)|UTR5qG$5rqh`8 zWw72+8TKoJ63k{E0+`O5wQ-OR&(~SLE$a`o{EOIqEI(|_c}!2q-7iJoF>Zs2B4~sC zh!(xEv~i`4))-sUIy{d&DNxUqU_z8P%jcF;m_E_pjoGEt*C}%77s90oIMyPXn%zef z^%TVe5ZKtRrAiQkr#u{Awm)}dQ;3-OK?*aOLDWn;F!2G!clESieE){cAbD;5v0y*>~HnyqLQ9(iZz548`UFMm8c zVPgy9ze}fRPiT3wdB;qYGDhR*IfRHx9s%OHtdas~h{JbxiFBU1PP3zsV;MB0!kxQ_5Ae<_MeaLqU&DJZe%rX$YCvQa>&_}F=2_4&m$a}IuTB-Gm0dMO$aUV6gHp=(dD9n^#*le^Ob$0IWz{m9ohG#I1bv5xiCn8( z>uV!xDO}o}WLXCcv2rqso+|(lGsX!Hr-c7~J6vel(RN&jI{5L-qY-wf^ouzWt2)Md z33sriG@2Pc#D2Q;N#GgmJ?Oi%WB@(KvE`M(|E#pJH`+3VraH}5BDIYfQFl?ZIoBFQ z5`TDdyU0Q=5SD`}tg!2G5^_$)FQZ>hEz4hoZ|lwCz667|av}H;qKRzHA|@>*v`1Q# z4)T%`^?5mKrTH~Y&Ry}?nw@D&XVZ~OB>ckixq04B8 zqk@Z=!P+~Bml<-C_DfCVIV~uS&54GXd$Y5-C?FhcX?e;R_YWD^EATCI2-DcBXT!Wr5m!g0sm&r^yWLJJ*Xffs?^0dGb4ZYLfPIoS*x zeEkEdx^aPN_Hx#qv%Qyrm&{xW{R*BhWEaNdpF?FJ0#5hYPCvr*Cl8z!1klzt^fe1o{Ga-4e!k0)qY zYDY}OQS$L4(CB9)Y8g0tKRlBk^-Z&i_QFFO zO#wE`CPmA&eaGuQJ3Pmf&7{UdVG_~^ZfRMTy?i!eq(5s1jb7WYn=oCudPx|Eu`l3& zP*^{tIKx58bqv7`mZ3B(x0BbU6c$2HywO!>i>Gdiju&tFRl21mco)hlI>JqgKMpji zEd=KBtZZAMG6r7pr8)AvU0W(CY4}n^&Cra5+8^ksl4M_4x!ZmC8b2wAZ_WSmt_+cc zw?1=Q9jrOmmyPrG3%>`tM5Fg1DBm)AnA&6eOx^icn5h1wR)aEXn6b087Hj{ELr2&h z5LcFOLJlE=peTUdlDSvx27Fizj~DbJk5vM|N!uG0!ll-rKF)n(v@XdX6{TvC6q@pX z`xRrz1RZ&|SBeZC9~>>cpBs2AAxenf)CI(vqHGBil%4ct(*xL+)#KB;qg8Voxahsv zJ|l0Hm!J1Xd;c*Be1ZYxIc%eV{HLfde#-_FKHr4}KZ9aamiVyQyg)1?737YBW`2&0q@Np5~j0&Xavg7N#zM>LC(HW~24jjt02@22Jt6kVx%-7$v$` zKRG{S1R0!jckENp7T<&7ED8;Cf&03PEE2(P^Dgp7bZob7Kk<)6-$qifrhtJVK|>j< zDKN|jVV|`b4mRWzR#TP3Hk)yeI|i#OxVLs(JAYhKJj%#{Hd7nnLNEzAvU2(}R>#q! zo)LeJdM+}HAf8bKr}PFIZkSK7#%U| zw(lV>LVTiQ5+uQHr7o@5gCB2$@JDP|FLg5EZn;C&_* z>=Ycla8_Oa#NoZU!DR5)QmV`JUL{$%*pA*{F%$A10pVbEE{cuw*2Au4NV!bsenB)i z=c?q24=V~kXLa@Uax)Wf1cj5f^81vNnbV~uBDM`Yl_&4N!VsBsnILN>jFxb(Bd|+57^@gkIAhmB+5?94(G0SA1wR#^cAnk z=&fXGhgfIJ@RDw`++yq<*rP(5=;vWbO(wtl6a(sp4)8*gq5lM~mQca6>p|eY`P6tu zm9=(@DrAA9NRw&v8iJzikVuN!efeBgfh(ojgtxsaBdMb4+p3g0H><1LZplI;vVl4> zvVs?;W+B`Ig=E@z56WK$IzA!R~P%`N>Jiyf!66xj*RM{!@x`HwQ-XV(??~}m} z={Qmn_|WzoYWmwLqi+eC^ctfXtDJzGJ>ON=_D68P%SgzR>yN1`51^`&m5vM^9MZeR zYWT#)V9&|sX`~L=huh*gi5S2V9dPcSi8M#ZEnQO}cjOf&H7Sy5!U{}rfsVA2AZE*} zX59melH}nfM(Wat*6jBGa062jCHIS@we&1I=s6OiOkc~47aU|x?8=eL*apy%lVp+e zI`dDL#zcZy;MzW>WOgIKMj+y6a$b9+l#;I+MwE71xiXwgdUAg4q8j5ov)+4@#zAc8 zg9S^*gcj4OT-W!yJ@L8clJ@pyu1^9_smO-WlNv#>pN#*3pPqmaG#}gUj3&8ps~KpL z0Qb2JLbzEv-licBrK$cVlBU75IR;31T)t3=gjlaOxLKycPFh7SqsLIX>@aAHn3|Sh z*2=WuLuRNV<%L#(kmR_5!!mr^T#?5}SOoVZlcdz}62~EoKz3rblG)K<_+c>?L7vOL zl|gWX3cWBEIIQD9M*~A)9TdCo9bg&ypfZ{vM;kk)U#(uY zz^xQ=D=$)qIuA-)eF^%x;L^%(o%|(#C!!;-5N%5+ohPj#{H=G%79M6fEeRrA~5;wPjp83#0E*mk^f+0foq04h) z42d69JSzQQ;bb`OWyw3eg?Zw%l(^JkqEk7b@;wWe793>d$+xR2>uHt6PKz`))(kiY zFzSkQ0m6};fQ#(Q`~G^DGHy9O6^$;-ME|BXB`WEi0*D7g{&{fpJ^PW zXO;Ft&Em=lH!+%2wcC$$NleT}ws@21*v4e$XRjTY0*^6vk>25QWyy*Ytf_sao%!p_ z7C6~k^V%(C&ih2!C*2_{8RbQU#|*2#%5#m#;Ezn8errx^VjlMrS-I(j{s5vRf0BHa z<8B>*g*(R``wpJ#e4Mo0wOlMiT~${*B8~^kGIrS%Bg<(`tVpM7@{SumYT*|t!1O1CJHfCL&H#L(WK)1cxD&l zg!KIT>iq8zV%;f9K?rNn&_cWO+yrO55adOBeP1u>YZ$}F_7t(1Lj^sD=gFQVoF&et zEayUu)YRo;o~-Dq#CQq!veUO7n3O@Rl89=`e1QCmxj|L2$g!RBMG52Fm-0}IWq;bl zV5q&|jKmgzI1_?=JCy1^oxgcAxcpg_s6WmyX7PNgL?QjmKWdpNCh+I z4kkalld*?@V_@1%{1$S8(lkogb42UCy^yyPUa9f;Rolr%F91?>^;&pK2m`$U@Zo^4 z?*_9KwEoOHB&h8|LJr_7(@!TQ!8?1X@$=0SVu}~3J(&c8aEFX; zZi@H@-!mL#+bnQ^M0m?Tl$O?Rd#by(Tr4u{SeNEiF=3S$64ob+>PmXJYclqt``d*J zKO+Re%u3@6?>dF=tBwEkNz6bPKo5TKmS#p*DZm1Nx{KKV(}W1<>p0kJe}Jmq7ycrc zn6~&RWQ%BKJdYsf_h%n(-6^sn$=cxH*knV;+}W4q3kP`7c4A6ex-5?7V6GM_mPNc;)W}gP`p<}#U&SK)~TH?Sfx=L zo7&0eM1NylroS(o41Q_J>Od5y{xqlL0IMV)RB1E3x@w#7ty^ZetG4JW!rGzZ?lu|D z%iq$ML!49ZRp!U_$A%sV+MdkbvueeTWU(Q2FzMeuP-|whix07Nzxlu~=huAKx){B- zkEL0XY3C0Q^lo*UQ;9R@8bg*qV-s*?J+d?pT(L1&r^Xvwna2e<3jYI zYMATqS`ZuAvP`(eGB$?5-YIo%`CT`kh-zfwU{95OOAV^+gwnPN@P6&;Ie0gVv5Y&Z z9JgiAx27=E)=Y$avG*5^3I@5fl(IIARJthX5aJ>m1%vvjlQfWkH|4setokcp1h=%X z7E#xC0SS{;SjU>mn`A}M=6AWg3*pFsgm9n#zaB3J7y~fSbS&vx(uJL??H$Ne;+FHP3-i}K22nNWoE4rD~OC)4) z&*G4OUYOTxAv)H3>ek@L@AKq&4>39ewm(UOwj~wW^55tO4vBx(-E-aCFJfj@PPQ*%3bNz^_ zb+C$rBsVIl@}83Be1|tZHlb?X%v1AlJ5S52<@ts8lw6{;*(1aU@duR23lES~=2LEP z?((Exq;GJjttO9P!R#U?TwAoHI8dN-XTXUF&3X@2nH)wYsVS?CRJzZI)c}Q%oGTA3J-KWgPlyXv z*jm+HJ49*PiXFWooL%V zJ9%+)gXtn?>B$}Dx-z*(ND)d>7(#KLM7TrGmm*Fg8Xl2QfvP#Mx&qsCtPUWbRo}L( zzLrZp)84d8{yPK$&O`Yf=!4Z+e6KJKTmSnOxFk{a7;VN62C-qXrBns?*9o=h4KzoQ z*J-iI%4p%{I7(DBn@h)Uma;M>(C2da6yg3YaXxg3o!eTU+)dQ8C47y#qU#)h;-;xf zKM*1><;;9nlpB?gqf_|}ZO6i2_8n(hNQJi$2df|9OwFil;3EsrX0f%}B0?yHWDIGO z1ro5=vEWPAq{J=~61j10h7oGPoIHpi4*2VGp@x6FgSv>ujmY8l`v_sZA}A;(vRMxLF9sLG>v(jE=@y4xzeA$@3!1i*>)|Q1N`}J z7gW^9a>8T3liQ(_@55EcT744$rF2lOu$xP5-crAt`jtd46-hfb>FnBle}A9Um2R*g zQz>J6BF89h31I3-vAT#>*qi>NOdt3lA{5zFQ=dB~ddCmfRjEqdH0sU^*|8VZ+09&nr7h*yq{UtYS&mx>RCv^BTftowkUE!OP24`)^P z&l$GutdX=O#Sd_7v#)^@2~rtMJ-CeE5+HfxW0b+!gZyZP zI>GCYhEf%x;e!QymqP;9`r3)=1Iaa_v0Q=m$ccpzUO;PV-H4uh&jfGY{MiS5sYd&)ZKPnsr0T6M}y(3^&s-QB*#l%vYSEV zUml?37!%7CBB=%?#U!YK1}}3B@Vd(FPgD_s{$Uumat444G!|aN*BD9Qdtm87UfQ&} z0ODgbkYS@`!Ngh*vXMXgW?EN?|J|iUjX~7xN=2GgbbEzo;2W7wXuB0R=DlER*!y*+ zWd1Ldb*v`kg_=q#`t`~U%4Hkq*UM%U*`8|1csV{pF>YbveQ1QNmORdyNcM$^?8y3Ur#Bc-#Z(8h6IwUA zHH-W=U~+UY=h<*Xcg?{8eip9SkoNNxKiuvqG8mM{XhI>*o(vj^zh!Nzw0e z)4};Ac7Ml?m(KWcTLy9GUt~DXbu6xJ=C*k?#XMpH2rEf6NL(@1Pawq zAx643p#}#mkHv`C3vmGQQS7_uOWFd*6VV3mUEgDDv*jHnqBB|uqNQ5jA%0C?=kX_q zV{}%q%=F0&m-n#K16?4D|MAeQumpnG9i43iZCA7aS7tkbL*8 zfA(oElxM54X_e%1`aDyzsuo*jjT|QE@PjwBwp6` z=2Pd$j0GQ?SM^-`*3WJtb?m6}BG-zl)G7U){(^FL2uawL!6sMe9#kiOznf2YW~NX= z^;D+WH1;a>-8h~ad zWAW`5C`ISnN!qUZNuXW4Ho#6-JWz;~M6&@g>LDO1Xc?m)uMwB$vQ|8Jih_{|#lBO| zozN?#qyg@^RZ(zAK7nwB|6&ozbYYAd(QFR2-p;5BNfhCH!7;SMhFqk?s`*mt#`X|a zWz%%@)^?KWK6cA7uGsh9g5cV2l~ zmOs3)W{lqqGfQGK(AV3wtzK|s!Os!q99m`CFJn`YFS7S&6A!Z^kIDt{7FJ}?3PcW_ z4$`cHuLukn?TdGPa3_+3N!(*uxjjlVj%4>k07b+L<+bYbBTWY6p{H^b2*IP1E#HJ9}qT0cE4-3kYAyke4;KQWgxC_uvgAR4s4w&(m?+Skq5cFCvb z6gczsp*Zoh+!ntjviP0ifBy@h#DK9thh%+n0yO!qN-{<I*@d3MYU-?|hp97y+2CmtS~)8)_arfd2%cP9^PC4GZW(?Dcb4ii7AZ zUmmLjtHOg|64Sooi0P#25aP-=X~Eh2yG=i@Z%@4Mq%q0SObNpFOrm@y(Qb2)_V?PEQrsRJ9x|3}j| zHU<_X?Z&ok+qP{x6Ki7Iwr$(CZD(TJ#?8KW_sjVMr@E{9QPp&6Mwg+d!Z>(8HzR<` z`%hA4&J;&%hHE%6x|s2}U6KhML~Cj&*PM`zhc(>m=<@yu1R3c^m{=eUqT^9w*BbsVgi3;LgfXJNms6ptqY)JzjgHQ|H#=P<9nGq_K*#R|IG0GQ9ev@jpcNn z*4n06@<1}tULNtIk%Y=6BWmQbB^jF6CS!C(-C34q4gd~1#@m&;0?;`CK!}Xz@C6uK z;S z^<{dTj|&i5+)?|HQoGTIwd!!?#iC+z&!wa1EFkEFN@KPu3zId29IEH>&G*S(=-aAJBKf_s;kiGfOoyg!fyO`g z;N_#|K2~b>$e3u7K}R-XsK`pb#_ed8hy+rYApYebMbVwORc>((XJ5vua2R~{V$iA?1_HeeYluel zjX7~mc+~tmmmvo>?WDz>bm@BJirCMUWg2{spXB}@#BmFlDqq4wcwF>waU`F);WX6F z!wzyf->@pKVpITl3ENfFHs=3{j|BZ_=z$2g6v{WGZ`!GLC$GM!Wjol3_rsl7#8>jG$(ChI z6tU8-_f94@M}oU(JAtzMFjE(H6@IduMiTUaV$fZ1ym4ZNYZY+HJUw7Ad-@ti`a?_~ zmN!ON+YgqC^N>qp#i~0?MdcTQmzjTYiHVf;G-^uE{GF^y^l zIVTyHpt4>V#Xd0z*QtZ$z0O@kw3r>3HcHg2BMg*@eaG<~QseE?2S!?#oSh#mB|QkR z)Df}IRczF-MHwWMV=}I|cL6?z>-c$c#$eYn=yN1wdj4LU#3Hx%OP-OH&_>UW{tszn z1Ul2oh&|qN3dpx|g}qF4oI8K2-UXbY;U=)f^C)SLFL@h1Ki{3$*x=BV@p=B_#;opt zZPrNpJ9W9q-{`1|Ok`K}#&De$i^DFa>D*FL=wCcMY32BDAUNM;Qhxls<`+Qm6U)#QJ^*JYtQumG6kh3Av_%mAzG0wGy~Z@D zO>-0$=2+n@*-`UU6kUaOy}xB`C~2}p+Hy?7Mv6VhE)HyXG*_Yx%vT#=)>Attb=9w*OL=!qGls`0xA1>4<6M z3`=*9;xlO(!;j$>bZ!U|$QBARzzuFe8607SrP;lP#5rpsVjPJPog)o)?cFJXh=Kdx zDF!Jm00kQRi0C1OjXCV4p#b(zJHT0nEYmNF}acSXeZ$R?_Tlmt@x8&ncmw}pc(DPo;qK#udV zb&=ENlmiLW#G1Q>t{cYxKdrzWM41D`@GXhNq9E$E-WUcCFY#$_m%5rpmDXmLgt$eJ z|A|~}aXL?=L{{$;=fvGW8NKH1aqO+tW>Czr0Mb6u`1Zvdi%h1j(<^^Dzov?5;qQ@a z1xLi4{UaE8RVrGl>j!#I-eV^Qh=>&7Ak=)-TF3Bh2mlcc`7efDZ<~2`ExL^OkTkTg ze#Poy29U4=i8U`KPYK*uFfS!+DCN&jyc^B4#xUi{V6>nb%wk!8iz)bpnMBm4M!iLj zlZ|ES>J)wt0mOUk(nW?JC8(BnqNHGgh8mXyZS);4yu3 zVtZ2D`7VM0VLF5pc!UQk#L)4gDn(0;YeUr(5K%$UQ(q9|UEn>9C^@~w8FZi*_cz_BXL}SE&r6HxcGO7Eu^_ykFAf>1EaEI2#)^++8q`JbaX=BwnBrrllAz}9N7Df zNO^=FWea6Ns(WZKRhM`lZd98e;GBc+ydl)(N1fq=BV|joxa+3oGR!T?09>zah`mSTtg z_<-a{0)|Z)G>j;SAg)*!+XCjx9Ct~=?S_JikrxQBU@*-NAwkr-UOnZ3Bn91huUUNq zL)rl+F;fc}dZY5NQU&q5zaRUKQ;9&nQB>L)81%hCnIHfVM_ZJN+A_+UQY!2;*!yDoLeN%Y8mKaIi4ww?I(`~ zERODZ|5wO@8w;>(olu@dfm)8YpCeAS)SYLJ#A@Dxy27rdhi@kKqRtH;>-epDp#a!VW4?i zil3>}{fcrLVY~F@e^>|LSTqQGgYP7di4!}>X`_h6YNJ zlQ-GOJd%8mpe9f{&24$IqXUN)#d4$v;q8r+4pEtrDi#8AXi>j+!ymd5tLDi)5>_`( z3%woC>axCR)^ShZwa9#nm>n`Mllr*$z3!L{67$F8|74A&L;`9VAzp@V?!U*7T{=GH zdZz!&!>n4XanDG*gB_07Y=b8Qr9GPuWpH@8d}2!33OyItce$y`=S#dmfiz6w`nYhbbDGD`dd9EO2&s41)?OT9_WB=qde3FgXDZ3=B8`RH1_*rf{60&L`&u` z?3eqltBzS#*6`u8p|C9tJ-PI2EzAVhuy-;Mm3%T4_R6W7ncm*mYmQKY=1C5n8}mL# zh5(N^>Jj;PNus~Qk}vz3Mig0Eq!oo#-9u;zDHvwBW5XW|=AY>HmbQUIU72;!@IGv| z6+|`v7)gK9MX;0%8d_ig7pKu1iT2Al7@Y%ep$$R@P%GNWu^Z)fn7+D zNhAJrwMcP>y(MSc8N>F~KcMJa9G<93dXuggRwj9FaEh_Qa&*iTNkV%HM7SGj7~P52 zNveWB5i_bd-1#JolX>LGh1XMCCQ*D-T0ce=uLpfTv3h(YDJ>W5Y9&R$+(eIN19K2z z?g@S-e_Y@-aH)A9{Vl!P|EGfKDd$%Elvx1~%PP8q&pSlyVpR=YYxyAZyN_KZDgo@w zvG7`5m7i5W;=+@GSCc-LBV$C3RRsCRaEk#rInI<61kd zb^&glLzjeMS~YYK$ymbkv>@T@4Vm8Iwx6T_P+UHLn#66nC)TZK6aEIZyYG!c6Vsp769avbw7TSwq zLR(oQ0Caogm?#TUt*M4~PgYx%rlU_td{{w`Dg(Kp_E)~lH9R7-moyXltT%q#x;Fz? z{6B9259}O;BIxom@0ZqytOjJwb&_Dyp|+3c*~r&aOn>4)2*|{EP1HF0llUBn z$Lr=UQO#o&mw@l?rLC4VUe46#DbN#q!($&SO7S7HSMNiHaV6(k4UWs#Dw>A8K+OEf zV*I=8Ms=1iK6)X(6d)7nl`%p=qyfD;O~nj~3m;rWOxTiL!<1`m_<`&C<);pzcNdZ2 z#)Fb5)N&9AT`nRIwDkiUzab7kX?9ZEqbo&V$>IA(uUR*bpl zneDg7Q(*C&hnkl=9}1*DT@ZI+95%V;MFbe>{NK9n6AqfB69W?-;5UzZ^I(#CCR+85 zJ>~JAy!3o`nt|z0+0z;37d&QvkQUu&8s?%mMND61uK4o-%R;tRwM`|6=P5|$2b_Nx zk)U@VI%Yf!WAN|78pVRfW6eQp9%%-m1+AI*ILVe{gC7BHc@wh%)jfebtTYtKVA#%87gUWB-HT2J+a13;8W#h41( zMASprjNqzTgv*H#aBXoNSc>SzZ-YN|!-6WweqUhtmJpKzF9;t3F)eAl@p%L_?SEy> zWVA+n#tFMLef!yt9Fz`CN8I73;64J2MgAI)Eh2@#vaZf_U*ZkRbgI7(eKM;42I-Uz zPRECd`iQ?1x$$k#w~_b`{eKHD0EkLK_kc0#^~*`dN05`@v2jREp6{XJPB~LTo$cq$ zp>`^=hWx?K2J;MuuxP!`{r!Gn5Mhbg^OYwKq%JY-H2oiYO3xy@%ZsJdZ(6kG;JNvt zExE%S3-ep|cJmsIUxJ^Aj9z?|da&E^L*Y=qsU0)?a0|-9ikUh>j2v`?K;IS|5{)b9U}tfWQ6i7Pl`9yA;e@H5fdGhviRKcFmtl)-pCIq z^EA=Fm{on=lk=6D@FJH5XGROcWef*pX(%gfL3oKSZGfDVPd=OT`DSwdx5Fttz-j(9 z#CL?YD6xI)uvDA~K=8a(KLY{y1gkJT2pwd|wtzTVjd-+;0!1PI!^1&nAWRRmzJxKT zPf0r~B4I{NtFX^Lk3j9;(8NN@1|i_Nv~B1i&?O0?yBzF63LA`4WZ?b$c0?FF(7k*l zn6{`+DR>f}`stw%I+0!XW9qizYoVqkX9V(g1!HLu*?)cm>8`tiWn3tD+T>tz`c%e- zI1yQsz0qVtq(uniEm=952_q-}&G4v$1k^$}r=n8Wp-eA+Iv}@A4+Fig>39El{AjB` zBNn&h$M%SMW9Ks38z^gXYF)HRoS7i zplr*3g%8|ytA`&1_G<#gV$8igK!i zsdiQ!R>cYf(G*{b^~tK-;7z>71W@uTBfSElX&VIy{0fgNzwDyw&yH@h3x5X=BW6xm z_feb(Q#+2G%Z<>c6WI|}BT(=iBvxjdL=}FiI_DvzDz7SmK~$(qE@G`Cthyl68>c8z zENrRq*X;QW$jMjulQ)a{5m@B|{)^Y?gc|h`Vlc5&FNS(RP2>qXBG+R$@i9^fK#rnA z5X1Ek1U3TTd?!GJ1dVdFrzTZG76s3c4+yEDazUR&JiAD#(X#D(5m8?Cc>>pL26^h8 za5>zO`(`R0pwrNHYzmvY*^?so z*EGx1S91=ZVO%6+p%CF##J1aI=7@bUOpiZyjRLfbckwtbl+`_p6Qxsz5(B=jy?W_pEUlt>wRNTJqZ_`Sw};%^rh{M1c2`2vj@-#~ zZVysks_y=aaYIs*?nNP3%;FSnB`%Ra9O%f4z2c74fh!v?cWt{*sffvH- zyl{Dk9t11|$oGOI%$ZNO^P$y6eCo8iQ0l8njE+O)6?_1=oel4^g^IZ!T2=ODp&q6O zj)Yw7ElQ_%%oHbHH{HRc^b3rZV_VoW+S$OaizU84wmcWtj2Z>>{X*U)v*>co;C))3Nd%WExTfBxA~O|GvEWcNy+ises5hPY5wI4K0M^ToPJi8^THG`b_g7 zB*m=Inb(^3x=V{P;3KyDNz2=Gv9NT0(6~y$|o~FLd9f@8m(P* z|1+KUPyP2-pcT)(EIiKaXsaDp*I zKoFQ|u=DYPoQZNj6-c6-2muF;8x?EFJPM$bv-B~|Q{JGs92@4J+05x6P(Q>AGexAAum1&RN@UCX_m$5+ zb65pF#xtUrkgq(~RZ6!+)6j8%2eV^M+0puq(8&Q;;qOCfLs6u&UIcs_Z@{wC`<38C za~i3nZBcO*@NvF5pzPQ@o(==86YdIE0aJW~C0&CHq`nxih*E3lBDv2tJY18KmaT+vHyF~o_qGc5 z%l7{0u_(Scx5s&eIdrG$oX{5+kVv@IFA4`824VmJ- z5(#^AF7MKvalhQRMRuJ-6JBmb%5GkUIMWVa-9UsYSEbeJG6=AE@Z;aZ2P_BK@b_dI zcW*3&%q8&2mym>H)azx7h(wJFvJfM5Mt2x%;I&9d(!FIu1!UVI_ywU@1m6=-6@&Op z`Yw0u_i_&QGj>B{h95?|IQG>r*_kS+YTIt%V=zv-_ZO|pYV?qsK&M3fkJhysTdUF}a=>%O&s&t<@y=ZmW=TynjXz<b*%?68Dz8x zH33l@4iF{<8rcJ5`@_j$8%VcCXV%Z#V-u44O=Z+A!3Bqbx zw0mtSEk(~T{5lr#+0A8WJ=wJ$SXc%k1$SYd-Tb83J?wkV{kcX5#^y{4&+1OJ)(P8f z@^8G;ukeKnE#z#^a}M^))H!2M4KN(U@*ZkMN8JK%b6<-IZ_MvFb`&@uEgIVx+KxpADXHibd=&|=_myA@!lZlC)P6T&ySG}T1 zrDZ5Lxh^yT1%P1*piMP9G7zf3Wt-JVTKtmyy}4d&*Sip328P@<&AJJg#(?^YM3ZQ4 z_Y;-uv^AiM>5D)6bhDJ7x;vcM)Ua|43RQVV``VRpZJk&mVd#nb6)+q{=af9*3R*dV|2Kte9N zAbZ>PaV^R%d`m-{4KDK!Y&`IU1Qf^iIC!>Or=EPr;L_Suhn>eeAR;&JCb0lj_KQOQ z*=Ly*)o^-ed?lreCD**41~5RGrw6j6NYNa5nN+}$&Ob#HH3dM+F&hps9{+@v>z9iI zW~MvLnD)gTi>lXq85kYl^Ml zN=_9yr<>1w6Mnc#XkUdE2kW+&0PE{NdPXW&vF=DwX?`Wxju|WL@)8KRX66DEkSpY0 zZjWAE^8!jqZhCbqbz+q4at1)AVcHi;xOL+3he}}>2ISTZ_jjkm!Khz@Sj)GCC9no} z_!p%1gQe+hr9KucY`TxtTi@vYkvcT-7$~a*=X8lNLxB@hRWU~{JHav(aBl@T0gv}6 z-t(&3S7L9CS{bD~<)jQg`_d1|Es^q*dLn@a=OXkdbs z{*DdJ@mFtu+GLocTlb;pSli`BKQy8JE6rzH{t!nURRC@VRvZ!bZ8y=%Fo!Q=u0odl zejWj<$^*D1q!ePOCZ#8yCxf9cyl#%>=apoO30b3RCu~#70_I1rDEN`mv9Yt7^~EfI zy+js!bLslubQ$To%LlkLo!C=Mu!I+55>Vruk+<_S*dM*)kIQG!w-bG8FGNz?_e?rZ zyKXk;t+o^NnZ&^3to&c)x|z|CXM*j_{5^@sAJ{G& zmVRMA99RP*I7-z1Ve1!>A58K;T3(Z9b}n)BpMX0ZT)wwT<${bRQi7MGHYmL!l1H@q z4$SE1=^7IqgK0ZMfA$yB*H^1!LD&OUK+m=pPs-x~vTA=`C(>N257NM+zmby#{!>Mt zp;813SC3CZw&JNJGPcPnc5gA=AK_{9*Ozm%+{$t`fGO#jSdCyiN=?VcPA~ZXEG$yN zE(j4<)q2BHymr%b{;^5zNZ?K9x0{Ffw6)-BX}-o1A80i>Ev2SPtISn)l*sxVcg>Kw zAr0#vymaQYz3!9!71;)lq#ONY1e=tzEeJ>PK4Akw?_SQNuoN%vCabJblbyCHffC(6yjaPbOC# zMJ(LIGF`dItPrOkCRXQ?wzt5uZs-Qrk*I0tnSHGH@#9A-A-}F1ysuB zEN*na5?X}`>n@R&yzhg^XNUO%Q^nb*$z!8oix4^u!uT7mGA9kug@i;$SNnv6As4Gv zfT-Ex&=7LR`F>HGVlMA{A+VlMa!mz{bKLYEJi`#rW?GK@oc9FZ{ ztLHe3<`t>(YtvCtX#e4si9(O|wipUIb6?b@_H2stV)M%!=-G0jhYalT@qLnu`$=A; zAJa9n{|nN5Qj3j-;XB==S%oBqK(}^OuP$C2d3c4>JT49%@!wVkiiFUrY;Qp(hE?7m zd%U|F?z&5y7Ih)H-dKAqI*pIB^X%%oR)d1+V$@zysVCZdHjQclCqKojGTk$n9GACr zz21$3J_+pT>7q-hj3%&v;t)*~J0;58cDjh>MiBB)hc8_8TbpuHW&JN`TJQE@!=eqn zBDF0Rg&fsgObs@NAE4fI2d&&XGth2g)I5cI6|XCK^0{i32k2VPd0o(TBeq~+EMgr0 z)=%!7_I>^e+-(Qx?x~O`mx#hIy;+JPq5}`*Kre%(@ZHVmlDP?;SxKc7B)`bk7w0%Y z4Kp@o>M&@84?)l2FG}FkFjpJvF{!n!k&Isg2_}4E`p&~@6K3TAlbHOHE0}SS(u2-1 z1AFtM2k)E=Er+S)m8-L4=5G!#^r=YwP6--@B!%<_WFT#f)l3B>!&HH!SNOY`s5n2m z!-yYuYbSrX!XBydg6p2dV<-i{8WoLzx&{%wT`w`tTQ)1@2(Zp&1~NLCLuW1%8lcz4UOEe>LzJRPBev|IKpkLe< z_k{8XnWh*4Q@Z&3oYTjmKq; z+SK4Q+8ts6l3n!OCXAY@+$C7g9Zr7QCA{ukjtZ>1daOK&?*CXnrDiAK_wo14HgziP zFMew%cDF)#e4xhmu>Cb5Zi~kUKqub#?+op03z^q6Cdr(X#j-cIVk3<=28Ve*$zNfTz|@rwu)h4Ckx$mX?Aa>@Lg5Eb-#++?~#%45b_|34^;L9@3E_cr-(*E;KbKD)NSY|EX&q)gI`ufhvVGu2YA$5E~(*WvI zu8BLM;b(%&l7F-e(Lw=G`77pUVygrnzD!)R8_-9rHp@LMKx8))@gO5=uKjjr5>~0A#sT{Hp1ldC&V|kV@;~n@A(ctiiZs;YC09Mop`%5o&3o*dXPQ zLHeu>^{R3GyAM+9d^&5V%f99OjXG}&rbwI=g)?$27^x&4dhnq~QFG#zUvkp%=~6%?rLzeI_V^ z@Vlpc4G2HR$LBF+kadWAxFCPQc)kGGnj}l;m!fV~e|^*r zHFoAnDYQmipAsa@j5TGeMv2JsH9Pvl&T4K zUDvCR(j@ahgiiLn_BsF-o?!lGTKVZgqS${+UPfMCHjiIx7~{sUVI}7!r8AxV&fwn! zdNSIC`8;65bEBCY+*=LY9cXhpV04v|`XQJf_}oG9ce~4c9k;R+VA8^hlk{lok z&L6IH1OV?P-@K*dnimAfXfB%?J$7^x=I8wqZd9)gY$b;c%~*;dhc#WrGuW6xEntZ;-)*93PSJ--$V@+A|=eN1=|Q$ z1X;_tam1+o4XhDqnQY8qxWZ81Y$2QsCfyY8>22z+0Ck*$Ww-k_lSpeRPZGIXHEd&u z@}0Xv9T?X*7{#yH?dkzstn&Zt6k~pz zO%T4@si?ls6d?IXm#y-J`dj0CH=4Pha5=z{oR;YBq1O7duC7ecySyG*K2!1jwbZxH zZcm#7NC_MwxGVS*XiS+yF`5$~>4=k(xkrsowiy_s{zn%X++ z8c``%hXt?53&L8Pn$@NN3{kyKfL!cU@9S{qk5L0fHw@{>!$kMkwSONE^c4RScS46}~(sMqT#Pt@}- zru)gbI656r?e~aeFj66^1P>1C3A5cuS49k4bzABWmIYnXyl9x-rP?8)gXAIGh{FWH zDKGxmsqcU6PLJ5eZc24IP#KG4g+zDNmzTmAWs01b^xL0PNN%;ss%hC;MG+D z_%&sJVBf)as)d`Y*?{utm9^=&Ct3orI02gWbA~fiO}IagenO+9kzo{nE(`%q4#>G*K%6&OI zFPc7fZBH3nFQ;bAos`?tnN1}I$hJ$4@;*-S$$YdVN)?W}cmjuSAAf$8!%N~oW4IGHtoGfR0G@X&X|vU;$IlAw4uYcBY**Fa z+OaN>p(K{$LPU0Cc&_5Q`3~kLH=CCHvzH+8%v$bJ5rZcMc_v2ROq{Xx)Tu5oU;$r! zXzj)ze6Rw60SLhGE9o0t_PwYF0h)hSg?+s%USK;-@GY3ogB!pSAT=n}nj{d2c(RSn z8u5&=dNSU0MKa=%Nq~DWQ=*;4bY%S4$1A}UHFZI!_h@O&yZBA#!{`SJLw``=1G})H z^i7e>P4|azl-cpl@5?K&lCa7kT?m_X(YP*oV%#f6jpXf+#+Jw84IB5&I(gYvOpyy} z+mfj&KapahtH!deC~^_px@V5;tK{W;t_gq|rU3P#cwvf)lKOp{P{BSB(bZ?ddnFUe z$^($3p`c~gl`4c(iJ;`f z%BySL!_3RU4>k5PBD(HOw3|eKnS$Sz(%TFoBao=Jk(6jUSpZ%`4rA^Tne>{Zd$jCJ z_HxyB@cy%xhx#@>TshlMd`I$TJQE+)%X4t8_$QfDjqG^@VxEA+kQVuC*M>$bJos3K z?|U2Zq6ds96bRf5*nJJJdJR=eBMp0o7a7w0ug-H9Ip+xP8iP=+3v4zJS5 zb1UX;DD_ORIgWg^wd8DWY`0ilqNx{)oEp8MEil8xc8o^R20Js5If*q6wR9yIM(Mp} z6fv}H6oAxpwF`w5&)sR1gpVTti&Vqq@~>Pz?fNrsLRIM;OiGi!atR+nC@G`2{74aY zRm+O{fbcFn72%tmC!uYz^u*<3B&1?rYw3SbR(vdtH(Ek2TGaN0k^$U=k3hyl-6YM4O&Vc61(TJPz$yi5Qh=;hcER2v-T}_dfIqD^n|N8& zAw5pmmhkFZbVe1!YMQ`S89z0(yDqg-L#Z$>Y zYJu2}!*??r{@;G=WLd0xKWiU0d)B0TP&#wz#l*@+l-JZ|y`4@L-X97GQMH^$;E?p6hL(Z+ z=K-@zAuWJ6RUDj%lit4f__0IV)r(9QSGrk5s#oW2d(;|%g|+?tZr7CPe5xJEXCchg zE^d^l4E}CqCE;?Ci`D>+m$B3R!FOiVo?RqR{MOD0@_r1znq{yUeFrBXWvzsCU6`$H zU(SOEu{_;KlsQtPg=$5*g00*{Kxy0tzDI_MoSNbQL&WYQvLT8xtv6Cw&Y4nU3Z3Y-KE;j-D(^}&dJ^YQ6DlD0U35ZG-hsaALV!*+Y;CD^zw{+2x_pP_R--UZT6a53BU-GgAbrsN} zZ#k+?dv|bc>N>961tZUJ6L6 z_xO)Dkj95XD|K#BG49X5d;v-r1fB-D`K|#J= z;|F+SAgn=Hwe!9Co%`@FToKCMcC#?yIv-rZbbt2)wq)(d@jLwMLVvzU_=~l`;0{wm zx^xBVlT;tK(X$lz-rVT%^6@@1n)||IznZtvS(~vCOfzCj;;y8vvUEusRlepMs9{}I z_kf=%|M>CWB1)AKcx62FCSHwF|2dxKNv&i$`sQf8_{NgxMIr2}6!yJAW@0nsN-9)w zVg}hn<5z3k#EX)YNtjrd4g~!22koAvOZEXW7cq1rr#z3LURlqeVaKoLU4g(Xlsw8s zUu`H>YIfdq(Phn9VBJ|%YuS^4p0=6Hk$2_N4Xo4O%2{LT=Iuf)t|@?OE;{)DI?t^t z@<7UGan|9;tbdWJ-L#cHmli$NHfS+**STxUJU7{EN4#p*U=5h%eV@SHmy0g}GS?v` zSr8Tv!wyG+)B6~2N%TNmfprz@-9ilw4rKzS@F<53V-?Z`g`ie|9i?V&sQpLwAu-8V zH-l1IPD%eW*h0@r??>3z^gQOO{60Gc0-)F5vwB-k%>sr*lDQjzwCJKEXXoG~g@FMk zO1j)@8B%LLRlo?or2+M3odm;5UaD~BCeLx@D<)>hAzX{&M`pN2r@dvtU^p|Xx*Qo- z+_-;$BZ(5l;?5*EJ{KwqwwfUJ+H6r?UIVF+r>qHYbgV3~f{&=!Tw+p5dZAbbPU$he zhRqjombZJ=OUVufK>SGx-FNv~w_PT&O|Z&Fb9pQ2b)(-8aaW^uokn7Y#Hd3HP=Dr^ zf2>oh7FGZ|(wK6YG;%`1_aMHOk}Z`FF_E|}RKC&k6#w}!+hKdEb1n*Zf%Li!=(86> z41|rTjIc>!dSt`m|2=Sk)1+P0X@1_vnZRLLl3A7H{LK~aJ&FCXf6Lhrcqqv**uO)6 zO2w*ku%Nt|eyprJo^CIA?NE+Fji_q)Q~T`U;I%>dVfkDYHXmKtUAwsy!tPWZ)7wm&J` zXqk=6MNJThWCFy3?T{VcbAe)|%G@mbCO@W{Y863bhC~(#Tg-P)09Fnku+MOe7_N~5 zGjw4VbGw@|yeN2J2^fj80h+huXKn`$yn(g*scp5JuWjFs)#V0%N-y6ukg{~P<$A)RU`M|81<#t$|rb{d*+py~bNT+1;S7r>ry03=V{*rw6krY)W^>Br75 zc}ucEIAnen*I9m3+;Vc?Vl=ff77mnE0g!HtV4XER{O_EoAsTv=7ab2RGUe8!c%TE0 z6DJSaj`b5oC;D!AWsCdfvnDpfkB^A)ApZD4xrZC4F5V`DpnmCiH0#}IwvjKzMSbHP5q05dA8>xJ${-wgQ)%_q-atyVuIX9;+q<$u_sbB$Flc|B{1PCc2ZjY}Di1A?Vlq{xofk8RVl+8?@wTM+upRc6Iu9wOgTd<~ z%Dy^2v2;WK<}^6qbX#ZWB#($B`vW8Sq#Z2#8Ow^ek+qwj0fH(NP-4$qX1Ct{G0Vm= zrYS+Ed{~Gm3PPjIKrD^lzCM~xOZ*`h4T&7r=DC7yKCB&%28cTGP_Ey*QiV4@0Zs;}WvspnYmd93H5hXw1yfUV z*e@YaxSn*-@!7p*ajmzt;LfZpJ6n!cR7X;&Lco$PzsR0;eTs((*7(x|TaEO<=sRNk zwUdf+x?N8-*9yqVfiD^jvx;e4Cb5e#w7iG*>zgz>@)%V_5>fWTZMbXPGODPt1^4>t z7@j|>6})MMQOj?#K5MH!OGO(%kokIDrA-l1iC3@64nP9{%C#tH7TUl9gf@z{ zV`ZV$;p|Xe0qzV2B|TN64uVKIs;$YXxuoi{-~Gfj{dE%EmMC`qqLyiXKPJ z!XG<4!aK9yQdN!&@pwz5_ z!h*Dk|0=vG{QoMATp~cootd#GD9WX{7yFJo1+S`$>+|;z54LEFotym6G5e4z9q+C` z=CPhi%C!~L^tA_X&mSwiopKLsTD^^S+$QydF|TgR4xHhYRCyhtNaGIzLKke|s++CL z2x7dVwa|R5KM7*ru(O7h8_#1e=n3E>faqf5($@dBlsj7kv;@D|0kM_MjP>aW00P5S zuUX58{=IJH*gZAn3N2iuB_qtSC754Xk|LB^x1~I{g51JgY$p4--D(!5aw&P;2oa0! zkP)h$W_vq+yQAx+&xGeZ?b!~BZAFNL8D+{$Vl%Msg|t=;+Pp$7C{Dr8G8$2kh9cuU zH*#I$cC|)TVIIUO9r@QUA1D!oaUS8li)6Z!=`T+dAld8(b7JdY3_xqzz@ro3>Cdh| zhfYxFANBtLyFf(0e4nMK7n;E=)|h}F_R2;%XR%4FLH|!tOU&aS*HJyLqt2amD_YK) zZ{+KppMUPD+8b!0+V$4B8;|G0bwZbSpfW%Q&bdAENCvn#8|!_FWySn6tu0byR0vv7 zndmF-DULlgQ= zyPufGhv4mqOmiR=|5*@yNlA$snG*ZQ<`w-uDuJj3q7pdNO0#+K133Vexk>Rc$raUg zvsB&x`}PAKtFR<>+p>?+L$S{LW8xF?LVj}r_Jl8BJobE}>&OH&tfGl1v0MC<8<#Ef z#JG}w2cPiwiEZ1pAmqE2<>NHJZp?fxyQ>glmAr4han0z|$pi+s7ybD}vc3%CIXxn& zZ7b*gZ97G2EJpLpvXbgWy5i;8TA!SJIcnxs6weij?i8iAp_<5{hR;yeFOYF%;S;ai zwMUL)hF90tYLV`y+lvab;LQwxxO)|e{L<}Cr(^>bK!ZC>eTUWiVzpt{vKg0!3BLjW zCKVPsQg+3ufEn@Qn7~M$^>uanxtv=cl;q`DiH;@Tj6A0~72H=8$P9&on=u9yp+8?& z9f2Z=n3Z`+6Y}o}Z>id*Yq`zeewUG%S!nu*!rv-N*|zJCH|(|^tj;e2ft*Js-&PLl zDoM>HvC0VH7p@Ez1~U8B1O%{s$)P{Oq)=?w^fhT|i+5OK$*YtkD8>SkMfL4TlO(5& z7ZHTdC9>ad{a*B!5Xno@)3wY!d-Ovqo<|;k4A#CnEz}k`aSSl5Bl(k#2YLWx)V9dE zHKj5<@LUu^?#Zs;27+o;ZP})M)^8y49DeP4 zW_L)7u-FV+v34y^&CWK56JIHJv0;n`UCos}j<##c0yi@Dv>WD(;zCS`i;1_@z&b-v zLr1?be6hI42qBX&vmlv@ujj|HAfZKCY}Kx^Rfav3GxNOuJFI!#+vll%pv6UIICw+# z-Q>D?T*j6kxR8tGeY8a-5S2hw0zV`HQO@h~hc(r9+PKhrx~dto2;c6$yP|pp3)I`& z<~|^qam^-xU`LB%#_*hcBsMw4R=>aEMT`Lx=kz#hgLKF#C~;=yWCSs5sMg9_K+XwT z+|6jq35jjncBtICMOMU(w_2*EYe@ z*eO$DaS=h@4L=`+fNXa;uOkYItiam2p4226-Ym5M7!VgtAqwkN!n8MHzKmRwZ7uB$@< z2lS0HcXW0UnPOL^wCIF;=>We6#ZPA890$qx;DLDmwV~JCZ;?D0$@6j~4vbj${8~&~ zn_@o(6q11z?L;hf$05>VkwY4M7oy-Xiio5^isB{|pp6*}=bL1?#?L1g0MJ4oClSV~ zfF0A_1b7ixV{&4+a6$ItzN40-N)#Och05XVxRvAWK<>M-ckIC)Q|1T;_V{b`s%qbk zx}$0QkUQn)wC@8O_U6VmL$9#dyyi|zzp8F;ncVm;CkdeZHML6{0{SpneaMe8Ei22g zexyI=z=!KAphL+DyNssvpWPD^;h#J)@$DP@^%BbW$e0(0-zWP`fyqA(Cco5}A_LP) z2`$r4tlGI}D|Jo!&4!@n^}@TM(yW+V$({WMtCO_-wdGF(ZidVLkswMwM_W_^Q3*sP zU`gOmK=WO}J&ATb=8p-Un4wj*4ZDK6ar?11Jh;^AFRwQ*|8xMl^hq-RHm@r$dyfdF zyXIwgHGzhg=cK|)yO@=BaRfo~*5#`Qp-LWrdvh$@8xpt~e?$*Yw=6W)B2C86Th6o2 z(C%W@+@Bx{h36e%M#c<>q0h5&&{)rH>3{cCGg@hO{T3eZw-DXbo@gqisl4elY?C&%-<%GrL%E9M^<^I2AOXinkt9*Hl0c8TOyd6f0BY$gMvUu& zL%~dWY?ZTPvn|0+m(4JNS9nw^Kz^5#m=y0i2N_?6^37&WC%WC35@?cJ201FYlFV1Y zO${IahR%MEoQ6e)P5UA>b)m?isR=kDa0ko8zb=oAT&rR%Fj*)(a@QzF0x;5d+a;Hz z*?igw0Fsk9-%|j>naH#qC)vst6B8r67ox|+9cWr#^q@(Hm4B$E$R6S@QqhJQ@p$lL z#APIIBESrQ1z=fMC-Rca(|fVBY{vSxl|8m_&uwAb7H-ZRgrQ-HsoQ5o;j#Tkn-pnH zUbha}lBs7yteTk_-em`_GYJ_#lI7_~2R}jwIp^=UMyrjOM`L4C9>!ubMmk|>s)Nbw z_E%T$LMpd56;#9LYh}6o{$M|E3&K_NhvnOacQbY1)Ry3>=R8i&MKVflpzbxJq5kG^ zF!Y;vH}4iC!dZJhu35@ewR^<^rVNo9Q&QU`SIG_Cec|L*wz!yAs1DwBU-h1MU51e> z5mR}WPIp-=m7h1mXz!>5q7wLTmB8A$Lmi!yd+TarnCqrR4~*nNKMmDjgC20--S(58 zj5M8-F}Rm*s8c=7nwwUXn!8qm~t?s{Rk>Hd=MCBfmk*ix52wbJO`eJZ@A z11%iIq@2%|%fUwHlO(SVntf*SrkSuF^FDjv|KcGIofYjybUP#Oo`S~k_n~$q0nYi=Q>l{;(<7hoIR&IP%F2n?f+rwL+pa2-;iA6EiHrZ$!U*tGD0Y}f_xXxyFlSJyvR8+6{AapQd^C=fZ_wtUSQ5LzFnrcGD0#q|I3PocSNCCw>?O+SRHB^3vM~h(G~IGJd9mkv8;QkI z=^Ah_+MSee1$;uKdUsg~tLWKv&e#{8J@4vjIZkA|x6iZ-(IB_70?$qIG~+qB4~t0P zee4$tVk(lBdW*y1*^y!1vj6Qxi@HFXxL(!u>td4l;WcXuQTseaY;s zerv2_HSuo-5B6El`YAb6VpV5M7N7f~TQ7DwoNdE(nE*3bW_$r3B^%Ta$qW2CPLO3h zy9E!K4O!WH_EhX`+LRleIvs48*|~E-20=>M0J}@M7Tu|}l#JyCax~0KfJ``|iNt}m z!3v;?1iBQV)Qjr;zRw~v{F*>U$+8s|rWvKBnZ|F>VmfanvJ>iUX4-sD-NYU?# zk9Pueh}c(e%g^rep}7xc*GO~ZuQ}C7KFrjCv!&$x+$48W`u@880U`y)j(uUoEE(Uj z&g2yp$SPq0(E)LlJ9fWU9}EtBCZgsDHKy)#$HYBO22F7}UPH0A*JS@_jVX~rv_&QG zzb65)szmZ=zFB<#u}(#sniS{0vZAK`lOQ~r<8mKpa@&k4`nW@l&HhkqLc%~_Rb@2` zzPnrY=$vl>5{Vpz6da0l&l4Thhn4aDSZL0~LWAy7A2mY3+0SI3y-g&ZQ(ypq!j--A zK3Lq>(6rH>FWI1oV43)hxOsHoYqSF9wl zJPtsuG9$y4SX$b|zme@v448G6UD5h7#ud2KPNq09Z-oFHP#XMoL=&kW!P0mg5x-61 z)eW+bw7FQtl50=2cRYW6FftE}7k9~RyZfDGGi(>5xdC>{ndFi2fMukZq&c=&X6ouC z^jrUq1?mdMe$TFkRwcJwyvZEA@gH$7m6oO*ws2)+zhCuU08B(~7_+nhBxCDdh#cFD zc2hjZ!TmXzGdTnMQU>zDwWPFm;>Oe9u>xEKln9@duM{ETO7xL{7#C@d6`L~*Vx&*V zFMkcyOko?ggw0&@e!(P)JP?GX0HK{6580X~y#`mI{iWR7%zoCsIbkfPOBz>l8gjEisQ zuc`6z<8zq=BO^!#!EX06jv+qfBl!%EbgV*{Zk%^a$Ot|QsW=P&tQ0m| z(CV{hx8;`P`Yqng$d!waSA*KZ#8&BDs&?+)!F|zhMpn*B%e;H<)zdB}Fzr>3Gd`xK zq4NI6UK#rbIc8)8!J%`AevV2YDuMql34{SnKE&W*vI15 zROgtE{q8Tc&ie6X%hLZDlRUxUbd3oGB(J{ufU73y$r+c0qm%|0ZH(!VUZ-1O*s|X( z#h+ejQ`JeHw6rs7%ivb3>SWy?n*L1hu*=CBTb^-yd!BRy)`q)1&5}Nz1rrTqO$Vylat4|cR8NpQV3yBrt@6>*h+&b_14ejqOTD&0J znwnt@b(EG?s2Q@<&ONBMR8yZ{^TyOgfB^~57k-Eo>_2NGvasP1xeMnB11mokQNA0bsZUf)glaougvCQR9i{=^P*5 zbWaxZWfq`FLCMmwX*-(_*JP|8>vOpMe%3ZQ$WLlQ0>UAw2-8`T`Pg*llXi!5i0q8u zgRZy)yT7J-T__kD_RIxmn=711K=hLi@`IfKdO4FQq8a`{gk4j%3Gp%Ul2Gb1LJe;o z`_jn2TLm&D?7Ml9F$Wg^5I?55K!bk_uBoT2;TtQ}zLFfDe)_&z$}p7g8-%a9!AkaL z^^3knB@mUsf1d;l@?j|(-h)RIR(@L+d2gdI?-QJ<9mMkcj>la!P3aO&@BTbI!&R}# z78r#>~5gW(vzBQ zn}$`piJv0%Do9K)7rxkWECD4q5SeKRfYNMyh8rO0(77h&Yc{i!RfjXrAiS>H2v{jz@r{0<{| zqSQzdKw#w>I%qWd4E<;wN$iXhjG%HB?z_&IJWl1DaO(kL;JTRM)P4*wM7I#R1Vzdu z@xrbk0?CX%0>p`>#^)10({@C#nLc0PHbR!6uZ?=;>&iB9+mFO!74J$U-=&yI3fS-p zi1-N>7>+BzMau!Wl#&rTL&2aFv%wHWUJZ!JE-r2&g|q31jj^=9pPrQvpP40x6u*jdC-@UO#NZY*Ib56Xj)7{Qm2Nh${_weyZ8fv zqmx<{z4f4=S?NC)?hQZ2UwsmXxTg3~s86TFrMB5uQ!XafF+@$xmC4FQ;=~MiHxd;v zm#8UQ<<8wNVgejajt4X0k`&5JDQkE-CBE6s)R_7jczpGvOY_!CM&!~$s*);4_eXM& z5ThTW5{OFRUoL?|^U-Wr?7hei5!Ye29p?)MXYAEmKiK7laB6-b5$B&cWGIt-%pMyP z7ig%TXN2?zE%%W;*Iaw{Xpz>8@bggdQmOkaDs|VfQlG4bwAqIO8VJE@imnW1?7NfN zv}{(fZASy~DRXoqkUul~hV_REX8g5Nd#k!O#BO)wCc2ZA^6EWe@mQh;l?9KyKDx;4 zkAgLydX36r#PY=_X@+_o8~ag2I0N#rQe9RKaPq^KQ452?z|uLr2Q;~dr1-EhacG0L zk>ew7u9Vhk)K1d+@;tpH))CX5s3x! zOAKT;W;>Se%E|_|B#P;LQqEq=$1I0aYwvd3O{#w4-6IP8R7OTs#7^1U7iRx*77ZtK|xD~$f7 zq1x65LseUz%g#1kfM(~2e*|bvDlWDav(4hYOyr2B5*LlvcD!0uA3BVAld9mBM!kY!|am zqC-_3X}HpMHuzQ@w`%3NGLK38c^fT>^gZfLPV{FtL5B)Si-b#aXmu`D>pK?5Q1b*E@IBPM<#AHEPr--|+WdI@M;k|0%gm%hO3>wH~YK!0A15n|$W? z^qn>=))D)(L=Qj!%q1k;G(Sgzs9!qQ$o&9$O=RrI?a}v92}C9E+e*M(*+MQEt^GZK>dAwuP&ywK=QQg zYTw?7Ts_~q;*+awitYEF^yXrrA;YloFl+C8DyhaEyQ?N|xXjah2P5k+{rw zf<$F6KHV#KyVXx#y;%!l>XR#TF-P1M>xw%^Km~yh+fWMm6r)^X6{C!Nt_THSpRxCH zNXypu-2)csN`5{(An>>ApT(Uj<_!(c{c)-N=p@6Mh5OB^{Ba* z;M5w5C|5}~YjR_Lz^!yk(W>Jp?QjY%qOQ1#&IA@@F!k0hj|UH+hNXoJLcRuZEs0B0 z^r>Ak_mcky7aHy+zQg5I#__21t$m3F=5w0JAC3>nRNpTvC`gC)imi^~jmE7y~r>?nU&W)w*0O0e;dZT?tI zbJpvjw~qVh!^^DsGOl$0O`0i?Uplf7JfhZ_KbcTq;a%@sdtf zztSd3c$>wvJ{siW>4a;KYD_GJYl(UvU6W;`B&UxH{Y>j#3xdMA-~J8Kn|ge zEp{u@JZZ_p>#@KFgP{?#vU2BG=RLmfw6mD|%cK`?Q(0H>IN;`P`L2<7BRA|XzX05% z)(v^IE0|jIkG@7F5S769B_Q>Iaw>XjR;1I$1>P1~tTEYP*9Q56`rqn}m^)7DKcU## zb9C{loI1_^u+!~1K2Tfx2F8o=bI%V~-4@=2Jt;<;+P!U)i$9~7Lxnr|EcVQ5@hib5gZZ+PZU*2|P7vvb21WNQw0<;-ef3;1uM@9+e7 za$EQ;lsl;Xgvg*7Mry+we|+|y??pt7e1B^G#=@rAf-!kbLb*o>MI4m2+|N+eto1S(|#vOuUg@-U1Z8R#j#H z(ECQXh$iz_i=XqlvTy2%i{e1;8p-?%2)GFFb{WU+29QaKiLo;TDjh{%Q)9ZAHZu=g zXvW)+m{%N*_$#`#xVTKu&i?5x5KD&Nf5j405UyOUu%!K=s?|5Y2E`)Lc_M(WGmG4A zxQzgL$}yxni6;`+RNh!x%rJP)N31atmqb(&ZZtrSzqUpcMHz|z)+6(O@ZkSK*F&E} zM&C5MF?;l(bCZR-xVwY{V}6$d@mwq!hXFBi?G*9=6k}8no{RJsu1n!h1TLSSdaB1A z8=FjQd|U3XcHA#*h%al0dTR#|ZiDTo8AB#RZ%TA=K9X}tK#Lp*MGlks0ALZ|!Y0X) zBY*OBWRqP4!HSzuUX5fz*eU{n;^RJAnQfj28|ie&ZfrbCO(yWGnt+u9#oO>Vu2?&P z0q?`V==Os)c}S@K+S;!medWzPzw7&QFcT0_bLwyx@8?V=(yB6b;7o2p+uUSNN|(yI z{c`h-AM?_P$>#ACtR=XX@`{)A=a|nVv`BYX?byB07sR4;VJ<5M5@6*`Pw;HjpG`?f zySAdb>|^e$Yae-e>`pm`lBFD$B=)0oZ3H#KNs0+18?&=BYmOf8=tqwp?GTl~|D*)u z!TIggHqviI@#3>= zL`^x}vDZn26N@UUWvfr7_AGf-o=4KhYWL<$JzWWE!&ngyC0tu0p(qPqV~Oh5=REq? zaVxEU)@vT;q{KGw!RNu8?)3?+((O|CvVMQnRy6QZn5YY9X7@MGLH3!R$3ifofF&W? zDXPw0tz(KkQJ#`rh^DOu>@h)=Ah}M$O?CR=(pXFUC@?TEQpJjHJe^p9Kq50gE zxk(EblPp6YrLo(uufHUgtQ}WeVXJ^2Q+7yV#PAVGs`%mGTG;}j47k_bm_awL%^Cc) zkz(Rzgp!bZ2~v?}0vJhx2f#tM2p>i!y_0LS5m|lChrf~a?jQbO^*?{el_Ya2nK)6E zs@gK98DR4RmKeE*i?gz9>1iYmETwiLe3#B0JL=Q~I-=!eTi33s^)6R)EvU3a<{i18 zI&!7jbKeVaY0GPK4j${~TEoybtt*ECEpl(li3@l$?^Br%pXH(xmqw~SF0_Cisq7_D zYbFSygEa1iCJfxy#twc*}5J$h8gMV4^0;$QEcQptH^CiIisH+6NP$4Ck_&O_*7MeV+~ z31Ap;S7F{pD;qJT7>;43Fh8kHtL*(-cW!2KH%io$C4_sfd1|yf#yyR2ML($hn#r$@ zeM`QJ1W{JsAA7Y(d}J6shE>2@#3d*RT0BHznzbd%D- z{#^DkW{5PKRH}6MgCpYgG)p_ac5m5tD2j&^kMh!?q*B7EFX8EbmnSv(0zY7b0ER-q z!;Hx1j2uHsAEYj$qBYz|suLr`kDe=&!3;mQ6=CC4H%r%)^eg*NEZAuD{| zI)^N-XD7Rxx=DZ+Md`u;yYiX=FSMCNA(>!>TBZOM65bh_aAGWCZ=j9>pP^70+rL4B z{{ybj7ii_b8v*^hLnGX*&DZwk_=CvIc#Zknc9JCa102cBg1!KsOFqQbC2Eb#8uQ}aS4E4{G6IFy#+Bd zo)mKxva&R(wk?3gw6MU5Q5=9C*8SCtZ{slp z$u`~(ks}`YJ=sQ|q;TB0V8AzWUXQNk!!_!fI2$*fvip{FK)m6-hKWsL4+GmUL+FUU`$p!Ku3yul0;E-d9g~nfZF573IEvD=K z;L|fNK9~@ENB<|9IUDRZ00@RB#3r9;x|Am5uP@M0b+# z36=eI6?I7N161hixVqjQyLk8}Ym7Ii=eaH$)q`hUu4y4CD$25F9N)T|O{I*1rd~+D z)02~&4vnIc;)tlO@+&Y8wjuA2aB(aPYJnBan=kp!8c$v&96S6upJ0-o{|vRpLUEep zQ{l&mw;+?D4_zd3-JB^c+n*HDwJs>Rb2y#=n&Sa6abf-onR|&;l5lHrBbj+(u&RY{ zH3b`RH5ke_4EqoES%cH%AG$BF{@IJp+hNTi@>O)0Rh-hBl_g>Q2k8g%{=|jUl+VT5 zxe&|L59kuv=i6>streTUE%))U%>Q`iaV-7Y@fCkcIu?@{50)1JA95oKw?%*pD{>L8 zP$R#CvxpZU9R9{k27wV_=~IRt6q#Tlu;vI8c`9R zfnwx+xb$ouYR~ha=(KgS*X~bHl*qKe_T4Q z$!%nff7|;7t2@nsyY{fkHvUYaljqbLcd-Rvyh_;JZtW|96}rQA z1&j24Jb!uuX24vkf}5Tvu(8|-sV`I1`R%)x-i87XH>qT;suSh$$RE-%E_LnQm3R&d z`ODcASCe{bYKn`b{XE8r`PsM6Cn~cPWmk;x`E8f3e4Nl~kyw73T2pSBj7-4H0U+4a z;U~N?pSg|sW4kuGQ0D$P&xpR}D zV*~D^BN2BuW@W|HLD+Ta`f<$tIHq|#vpa#A3Ybaff~0Z+CU_Cg2X)E-Arz$)J<9YO z|Kz<`UBoM7cW{0V-ie!tUF0-P;8G`rv=pU=Ke3u@V~Fj1FYdJM^xDn{+w39xF4Z2Y zbk@~XwpzEYj($J673AdDIudOqJ9OqxqOPo+#S-;nvatFdjn}3FAC18x$B>&*e4Lh< z7VgcN++2@SZS(@UCYVhn`H+~h9#K5%AElp{rInc#@OyYAY(a#>P#vvakP>_5Juh)1@-* zi)7XmziWR=F)Wtvepb#Kz>T+9v3behe9QYEwTFzhj31aQ7*a10VCO9|NN7l@+{Wyq z*0H0nQ3*sPaCixrE~ogGs!DKx^w_xQ_uY7yJ%-Xc4?we|L37-jnLB=qd^hg>RoxpL z_6K#FqgO}|tmK*V`zAm`#qBn2Dx31qj3_FJ3u^VZquVD?mnQ*BGa=3TW0Xr+DPvhJ z@1s|aRRh71tdd8$lM`(96;(QEexum!r49hZE@D z1tyI+XEz4Fl;`wCXvvJklo(Sx2C0_4ztUfivE}a^{(Vg|mL;d{U!$sTfpEf&eIU1H z*|Ov#Zl+%fX2eoPrdpds1zC&a@DalQ4qv%4IjFl%5+K8Qoy%#8E^RLnNg^4ERuYX4 z1pur9d0D_fgIShfFEaTSaQ8WC=u=e<{#~hbthWGdAyW&-)k`j%x%of6)|7c(jfuagoIV#n?+|Xhp%fE+MvfEty?lKYKy9&m(7$SG4$S zz|%N#+vLa!nq<4LVZ@&XQMWj<--JOkQ;k}}rU5<_qs$~ry5bM`{sNCDKlY5OWjvom zVKue9_ZJ__dixrb-S0fjQckgx6NR!ckWh?5LstuCT#&om>Rf=*&uuKy8Rix0=Q5Y8 z(QwzqyAwK4UO_A~L^Sz@3-V?tpuZut^{oBHtrQt;v~Am<)EG5*9)5{S=#%Yo+D?c| za`9FrE3uzQCO=A2TZ7JeR}bhPG;h1;3u`QSmGQE&g!KC>a?7yz%Ob@2c**K3nQS(u#Oi2TUHG1YjA5ROd?||B z&k5FsLc)#N&yja=@Xbs?!21^T$Y$=t&6MNVtp);nJFQ-QPz7@t!#h~wtF6{_t#P1-nee^%VSo^_Mby67VC^yZe+h<%RlYQ zExnM7@aETg?e$_}6*)x~O)~*nMhV4+kIaUb@uDp%f&W$sNa%K2X{MUpm?vf9B5wyY zz{5OoufzQNJ~=}sbiBc9D%|n!e{{iqyJI4WPkQj%dY|Xa#Cbitnu=W_GRrq_ZmPCT z9jD_JUAr65a|``=JQ#D-U|>pquXDe%WSPZt=oDl3RV@GSB-!0L^;Hl`bYpg~!T$@FdkXbeeor^Hp(?yJUP{IucJ|1^2gxRS`eW*(Vl%7wV5e#bof zvZ%7vmRCpt%6CGB{=Chmy))zd+_K1htoPIU&py?#8GUg$T?U(+N%Pj#@p3eNLlK z8>c6ZXaq~C&tk&(u7Y%W)Z(cavCODBaCfBf9%{qkpS^p?`%)7Mbqt0=onX40OwOK6fM`di*8$T- zY8-PD%ZhMcgr)1lY6Oo(!lMPi$gwbpshR*C`IFmEz>5GKTupn~vW-4liT(bL&fgJP zyqRhnJ6uX&+cCl5K6TwX(^^TN!#1)Q+Q^J(W)hYT0>~sZJm(19p2H6GAGlG3`!8#v zq)Re#>`mn2~{hTV7P*yqMJv9(R>jcR)?u31WY_1^NAg5?ckpYA(o zj|gh~Ez{3X_UVf}G4Us`?~{+cJmyZ>F4mihie$YI3z2+2QWG<))b9|N+if3xlpYE> zU#9;tgqdD9H>=ynvag(TB;_iBONFDT~q@9=@KwqPMidY##{_+Ex50> z9D@Xa?aUuC6k zxw(fGt?pvjwEGCdzJ?M53bbnR;GrfB^@nVJ+z0u66*4@Zzcnlnub`4e4n{Xjb zweMA5oSK`v&yuazD6-dI|Bp*GzdjOo)5YY<2V~T&MXSx!&y0C>^7sRmVcBQI+XcsI zF=~I5;7#t7q+?`TU3tYOR@={^;+M?KzHYs2JG4#7nbTTzC|98a3ChrV;A)~cWI(BE zsN4fqcny-p(q_j8J|5^jgnZmM(C*#+n`1f10t{WnrVA3{9W9)0;qw~G{=FoU3mIRc zf8Rs5zT=EFK1oV?$t)XT&Eo+1w-uQ2fD6p}{)b*oAcHl=sQHjU z$W2227bA;cm5J0f;v*g*Ma7+@9;s}Amwtn~bxRA_?Z<0|b~5)$CQg@Az$_h@Op6$e zcrZRlvy##QW@LOJ2TQXFz>!EJ^MJgs2bAG%s^zm(;T8}ri}?*9-T2mF2gulLd&;Y; z%e(vp@UjkGi^XX%qpINbOe!oiyd0k~|0i3c-2b)bE{Ywqmns$tIr5O=HDsn#yp9-G zR}FtCs9Oot)~)A<@15JT>k{cB2-|%9h2i0hspdy;Z=SqiRyJnBr_vJAP9but%;erI z8u6+eLt5UOFDBrxej+Y4WeBS%;R5|`Mt1I>i7=7wE1-@NE;L#3witbPXSDl=!`A&*cK9}7A4oKJ{Fl6N#3N0%46CFcDl<#XiAvSZG9T`VmvNIkpRT06 zcQ}Rr#8B!q)dm40s^s9hIyJ5tsaO0G3{frsg!E{CR$}Wvh+LS-@vOF!?7t2}>tSf> z-2p}mg&Nf{YSwE96`ZAxTA|`4T5clm)_J|QShm-9?kpkIVmT4; zaZnR7FV>%S9OGEU&`!bfaw`2!q*EurNrGhBmA%CJ62P)TKS`!1=O)${(?TO|CYgDo z^@Yo1V0BZsb8l?s{XaoBUn7_e4Z+~169a+mN3y(dC^Vy2Rj80HS%hV>!fTOJ!}22Y zXNuoH59#;6+)P3Je+fVUPG(L{Q>fmsm1~CbTbkgE^K_8w$%99MhqFu#h9>4;aE|#9 zJJgc#bLTErBc)g|qngCZa#F2+HzAM@O9ZVJ}t-^Qbp< z3=g6Ld$loc>XqU2=XVdDezvVjf5GjHKaLgJLyx>X`T^PR=WuUK8AzT3e)z*yu0Bo2 zpDuvDdC))Lp$pC{!Q?)e&rfNJKE7SQ2j|H6B?Vq*X5#~2K1W+r0{<-%5Wl|?!J(l* zWh>%Y?&o=sgQ>sNsRhTk$$iMQYUV9pJqQ!#-7MYP@pLZ$aXj*DkN;!uI{>SwvcK=N zSMyRy2%&^t5~`s{4^k8a3mS@mouDEhsB2kW9_!jyP!JVFMNz>7P!y>Gq1ON+3B5xI z>HW1S-|x)JJQ4&KcRyF%e=fYtdoy?L+_~l4bINwvZO{n3{$cN~-&Somn(MH3oq`S4AYg2O&tM<%vi!k2{qCuTDf!FrnK$x2 zbsRQE_uCP2pwwNQ0dn;LU*EdqmAA)TtXk%TwF^7I$^LNgRrdo3T$`e4!@a~-)a7#~ zso%caV`Ns<{Pg|JF>l%>7Ssca=1{!%(1@UjTC`N=m*$mW5nT!K#kZ2A{V@It;IUCo z?uF4Un5gxEbcTG)^p1#vw^@dz6gU)EGUb8!+m1F}hY*bG!lM@ifW&k^1Ukt0Z#c;V z;l@JC`y)vs1VAMQ$fy&kmJn>Gji7NiqOZDvrnN`iVohLVC=?IcLzmdou`*~RNsB0s z5<>H6p#AFJg6y-$QEs(OBlWHBh|dT+K<|JL^6>!+oog^Lhr4%o2r^$E=0kC)K|C=r zU=v!Qgjky>+DOVm3nWaSNj*fx>dYCWGXg>c=u8aG>1NVUNfARxfZekw1JW1mI3#FC zVQF*>>&u1M&6~5Y#enF{OJbssPEoTVZTTaDap^t(OzC%JY>b8?AfKV0dIBQJazUAl z9n%DaX9%OfVV>e5gl=JLPb{P5E zHOzz*^kA5e3#rUm|>Er2a8$5P>k2D@t`;x?(AWL0_*Ys=lZo^Fv# zU#K(st=;qr6F2S{k1)wkVuhpa{d~-mw+)x|_ixw$2~8i};)B#1V1&kF`b_{(KMKTd zDWt1!8R}jt>xm6thXS1elW1ik;{UYrmRFocP^5R%V$XuvL+{I{Jd`Fo=Y?Ail--G$ z{1{?4-Qe-Lpl47QsV43H*T0yw8H;paj!#VK!-^L}S6n!pTcLxU`iCWv9ja!W|dq%FrfxcXcV5=; zR*+iV0^5jzXp?RcwXJqyW)yhHEh_Y$Mc(zGp=(7|+Zq=0rA|YQ`+qfF1~qcxA-W$K z20t=MKldXu22s&%=FqlicbHy7BExZzrm=>4j3u&CB9aJRflRPrat($`g>{$)3=&22 zW;+o^{lEu(x>7v@?Mn3}b88l#DfEN2R2Z0yTjhaWU7xk7TzUU@r+OYX;aZ zNnbiJ9`0{6MpjAnfp4*rNi|_%$d`);ACknNV4kEebd97jB!O`Q+k=0XFl0~TUNA5$ z6LwdJA@Mp5X826YUAqeKzSZiCJbE(Bp~;GhB4?+9iTS^PcQM}ozj;I-{4tWVQgmrB zILTY~G{C5T7UT5+b8v_SBaL}VN}vnp-jR9t+eSPVlHgnoMSneSrAP9PkAzPzze?=w zQ6FGurOpC_6PaI@yAu$c@smD%OlMN4yeHPAv{y`QPEb@t=dw%rTe))ek(q=2j zItJ&RS@hxzg%j@)H2LY+5ifm4Ws&qIE)xRjD@47&^qiV&H7)Q*XaP)VE;H3B#U)|< zR$?|zEqt~isQ&Z7n+(O;vaPQ7&@<+HztYu82u}GN4=W>?6R?R^X1P6ElS z01^WS%!45rR%e0f_}<7v$EU~6XfGD<4`VgE58i=;pn5*R%=}z;wD`r08IN8t@;aH5 zFId&8w`Sq)#kt{#31uzIE5Kv9#gLqQ(lD28oHA^f%gApW-?{&TW&m4|C*ga*$h0F* zz(wUS&sMb!9Ir02)mGMy{pW;0y^Z@4nNwVJ?pQ0yq}>cv)*aXgf|;>{o^!0M$jv++ zB@@lN8jQng6uIj^#mmqJj-)a4{Hn$cMtw--C-8Z|OtNlZ#Co72fPHX{U>qVE9(>_0 zgW^{aBjW=-KMz_ClWT1kn9Uu$N6Gl$=I%O<(I`_tVqL)EFGeF+XU|<_T2j^z8k;*; z!!__}ItaH~cKUIwOP63^ry2pn(jF3`HmC=rL#!z-MT3bk>$)|Zpx8&m%ur#p5E?2Z z!pjf_1_?gTvb0*Yuc!J=K+w)vD<_f|H88OKV$?eD+vE4e zLHUTmtLn=qdDG4}u)i5s0oU*IdvttFn>V2Ksh}Q9vA;MbczntA?|T`sYBVem)1(^p z6t!SY`ZKhE&XBV>#gI6j`SQhhvN}>Yfv_MOWApwtg&WV#v7IFm_Kz5@c`L0>DdT=0$*hmSBV_G!Lmm?fV?}Pd)h6^r5RQG#V8jH z4-d0%e&o@kT>XO{zS;3(tT<6zU{a50Vx=o)H0h zNfJYZmzWp*gYWd4)J(WW^@GnBb{g%U32x^nC<=~xT-=Gc^v%^&i^vy9xSvPVZ7#A3 z_J!0)48UZRMa9>o->C)A{(sCw3p~RBHzpKJqkCaC{smHuTP>ksgo=j`iSpf$2fsNt zu_Gjrex{)6J-_zNs)l8FYsAbB*lD~DuwH+tr%J)uPnhz>W1kvjKC<+)C@I|hsy!n7 z!Lt0qZ0Ot{nc08DGP?fM9NiY{ z2uvBk$np5=tm3<&>JD`5)?MupMt+{Cs+QT-xoinK% zeX&TI>cd2@nN-vuHR%u60=kt`B4%yn!g>yl^;9r4s>XMLWMfV2Dd#iX^DLk3KG|Zt zEB_@dI(;C`*biSW(?3k?w31$kHa`Z2gT9AGHyi_1@CP6+U&>^MXAnlm(8J{f9RdoY7u)7v*$sJWDCLEJ;IsTTqVku z^2V&O=cz18kACq*7#}0tjLpr1Caa~t$!?|a2B)!fE`YPRC9`fCQO)-a-ShUtAJ*nv zzP_Mg!@&UD7U_rtHm8(Kw0D8q_?k2OR!;nQ;?M5O$#^V?3ojxoul|_S1B2{hdkf-2 zk<;1yYvu8% zbTE}gW@d6xsTr`GWy;k|tl=at$mH|If;E9<5`5Y)tv&j*B_vY~U|KB^*G2@DLJH7P zw?qOXLlPM9HLL^yE%EIV>Vv*whtU^c9@Pv%(~i0%=>mdN1{h$Yj&vm4S33N?kmXcj zVxmfn3!SfFUZ@rRnCTD8*ci=4@swhcFFR}+w|Uns;Dw*&5t$Y$D&{RMVLp#{Jto`8{)>R=cP;jolP>O%qnGG#PHwA4=Of*}`$pxv^3BsCUQl z+6-3bOtf<$X0Az=;Rk!er0i}-;bNQ`CwJgzx1SxZ4ZSBb+A4ruSGiPvXT5q~1Q;&;Uh*#!2mdncm;KB5X=Gf0 z{u_*<|4sdWAN*l+V@Wc1f;6fpXl`emZ3Qc_AVfh2Zv#dK>KerUBO?!(zHY4U!`6Al8S2koF%F6y;>2ojWQ1 zk82C-&Gjk*PIACDdkRb{&F;~l5^}d?#f4mkzpZy6E2wGC=<=~oB>ERa6N5t-Or+d~bZ%x7#+#L*mzQ@LzB>m)vyymr_;f4OCTPSl!xB zVT8XLWDa6wuF}txXzhh3r;k5plreGLqKDxZV}@Xof|Lr+o2BPmf1Cfn18yQ9o}=M! z3O;^{qA2^o;EXlE#S_=0+$jW^mdal5Vpv~14(p3cR33$Sm>QQ9ki1k?v)#X8SG1L9 zvq9K8eQ9w#TG%D-`=SNohZUKkUWTrHjl>r|RlhP4Z;kbo(K|KA-=hU6juSn%sKeHGbyKz@H ztUr_B&i@w7r7VDc=ZK1Ho}t!AUYinPlDK<8*dE4n+Q1O^CoE`lyamNy%pG>wpUhvz z&FaV(st}~7cCT>W`9Q$`6}>CPn2X6M?aBGL5ui_ZhFHUbJ+gU8K(@;5pjktv%eGQ3>w+xZ4eZEWBGi3mCvI`r> z9h%R(-!SS3yb!*C_K9(EagzIto3CGfQc29n@WBTAXT}bA4tKO`*BCq_4t`=ffX?24 z{%Z}^qn^lfAr$)vsz{v^A#Q{gEkgF9e|1$7eixv92=4^&F&G#gzHJocPK=dHG?!22`&UpoH<$7OOe_k5n4dCzed*83itJ*Yx2p{tJ zJOn;_{%nVew`WY5qJsh_&zl!6H*>rwn#@o5+}>h<5hu>-KjJfb-gC*bT584W97&OS z`4r#Bo^a*q86Qn4cx>aMR)WlZikaIQ#O~#py@$O_`3+`Y^QdgREV|`3qk>`;JGj4Zx!DMxzl^KcRAHaVc#cqt*Dnq1LEL|BV(PhT2t_ zr5Oy(sZX8=Rjr{9!b*0Gp!&*cxsShUf8fg%@ahqE&$+!%g($E}f}=91oMz9-KE04# zK_^D7*b!{C3nReHKa5qa1(vqW9Iw1Hr^k%}yvz9Yi@Wf=`Un=RJM}eCVc&2r<-?hS zN9vm#dhf}T=Y^XumJee-mMk!#gQ6*U%#5J4cIo5S=3O=tn2f-dilp9-jmpC?{qBav zn|WRCJ&@dfA(nf;oiTXS$*S!*0r8z0?Gs@F&JDv_I|8mF!gXfG>pIAZY?(RI{r$K% zC-1zHnYnoCk%od)+M5>{m>D(@;9rPkG{Vcc@p~&tW>ke=Tf5%p)l^ivMI_6pq}|uX z%Mi`Th>HV6FCKu1;#JWuQ#9xf;a1p@p7tGQrguBK8t-mO~{JPJ1h)7b`?DZ=Ko#>3py9|HFFrDUX5;i}w9QdSV)jEOovEtAk!Cy7s!6VlGB6$}&Jmx4 z6XXZRh;77bBr!%4{VyE-a{=6~p1^fiPLAK`-N4jTRRtx$f@#QA;(+`+Pp^tWq5G55 zb|yhSIvzpLdfOr*SV`6e%o*(q00d_l3=WwJ<|3e3+OKr-hsjmlx!>py+QAp*E1|Z~ zK?pFH3o$9Fr#?5{b<)ZOEflNyRco;0PFHcse(c_!m^Hv3>*cx8v+iInZ6>xHwcrx{ z#n%=;@fOv8!n&_UVg|p3NlX4So-&*PlG-XJ`LcS7m^4PrR%KWQn1(+qF)g`-6P7RN z)p4TsPZoQw-rwfPnqUE0a5ft0Or!E^@Hf;7HR(5O0S+oqfo%kSrV=}LVv(~sa1W1S zZMjKOJ(+b&=Do%}lJg_*Go2r<$bZHJj09ZG1MsTOp4;cfEmzhpdE*|64Mg`3!8KSn zX{oUCnQeHNB~xvMQ|`t6_ha6)#Px&FOMJw#<%P3Gk1jFFd;Y$6`>JK~Lm+o|pezd$ z)HRBgpLgoB=&w_mf3rp54RS-&0q}1>8f6W&ID#ZkX&Lkp>~qn_7SHHE>?flFGfI-Q?qf&uSLe8rt(A!_~C3Ei&J0mQj z&=!_GT|v{jf+^?#dRdn?guEmol!J@83ZYffR4{HFfWF)=RaSRNX5UXAbgyo)MAC40 zx0O0)!#&HfW6T;6q4cQ?cTu%!WTC`(a}%iS5E6lay;?g|9!v~Z*A6Cz)JOC(^dXE4 zsg>yCQdJMmWnqCjf_}{a)yvdmmI3DVRMTzSer`R5djsawe(8%|EbgacP5OOW0G;b^ ze)>CY+B84EF%=^ju1cs%@sXK0_SS2czZPW3Y|SAkqq7UVDP|Lb3pu7JQLet&|j5aLP{+q>yQVhDXs%rXJQxcE%M zW$9iU|J_G(P}4}M@$t~7`V%lXL*V3Yg2`s>Paxxxi&@kdi+DwyKBrHYJ$f!80GLPd z)28W;@hWfqmj_ZwQ6@uzGh@vAH-R|J0E5%ExV-2mS>m33a{ePLjDjYu`>K~BaPOGy zw)Sw$wE|I1ro400up@Mxtfx3Hb7D3l?i3sUddkG_==|gBzk=@$?r9KLc-Z78!M(a( zXXgedB7nys0=S)aKWPD4+#tT^uN+UuzkKN?LD0tWn%u~ul&y-oF#SF5Y3-6_ zN9KL^dCSL}m}>H9{9>=BESTS?>*=a>AomTNOC#Sk<8VF>>ry&4Cv!hc?DDmd@9_<( zi3q>)2o_+n4sv7Z`WnmN$Fpx4vDe7+^u%|X@j2SPyq6n=`P2yUnlhov`4sAt&)--y zA)w-#v@)fKYSu<$Q!&&OWQ+8al@~${^A+r^7tHKCay!b?i7Azv3=CZJxvYy+rjU8| zT@FidEWuP=zVgF_nK9XwWsl69NNT3QJu@fFG~fNi{!Wt3(;swjkjW%;bcER`t`RFL z@*c)K|5n!2)h?H7Z=*)vR8wndz{{i~9#g{A;xVD^o!~zFzGR5lS2sSO`jV5va>YcR zbIG%BPn^1e7sUod7e$i^&QHC}MG|gJJAw-D#h0CkbCs@`y_p|%>jn+?a zF(mOmhdw`wwgW?@{)jpqg*xfvwSqZrK(NWE++5;?7^G54jXxM_iT^0+ObGRf&VWq4 z%zXT;R1$yb<~r4{&=|zsSk$J1C;ol$oxd4lL(-AdVVu(Y`(u->5p||}@-Ll6Gb}{J z(~r|`Z%U?^dP)E|*wH*eORJPX80UY@(FqQK+;ANA_A%P1cz9}~X+8Z{XQ{Ee7gC93y~QM2!cg~aPYW=G>PPidMK!aYBE zb`^l}>O~_|GyjSxiH%f`_amRAJpF#pQH4(Cw0MSxzl|9_4m*-<+8Ozg*SAmkX`pt@ z+;qdV8Zk)r=*B0@j`MnRiwI}QZESo-w7Pz1Xu^f&D(W(nSHRM)*-a9u9 zUu+Q3zufk^g9Tge7Cb0Ng?feOir`gtvF2kRzP4oIKI2)BuU_1e3BG&bzh)$&bT@=K zatWuf<-DSN8Coc;tp);yBh0tP$K1%5$#+4pa+l3yk5wQ#kTvg5f>~W+3if?H_McBw z^Gtkr%U>KJ_40a%CNHEi>Y$KNbF63)Nh(83#1^dX%U~wG>gQg6!RD}Jk)>mKSRSK5 zwF7c>IT`9{I&*bjG8-U}2}V34wJTW8-YD!wUX&VHLLrfXbc8|^R1_9Kc*2O00X|0E z4hy4QVubbRd-3tra65Yv5~PbmlBKLVAll8Fd#@(tCY2J;Ni9&|4R|kNV%p);5YFtv zDD;5g7lwSr5TKV#t8pyCvx%`hgvK}s)AR!{xXuW*+s{X@xdt8vKw^rAWOci_%E;Q_ zE30FaP+m>?c?-bMP#11+87!5HPmAOTV@1*sl7v*v9#e|2TadP1Ip(V7`L*9^yxJop zC5QEb$>ZWpHo+|U1jPGvDRy2|?QmXY#k>P#l(#_gsvj`}tnh1O1z_C*B_xaj@SCYt zJll-l5(0rrsJMwf7z0pNFa(*z2TV0+Mkx6GJouj*h9AF8G8mga-yfS|3XOcrTbQQ` zHaoAl$~}1EH!zF*IKs7WogslpgoCY8Mxlf8Di zuY9>;<6d}j&LaW+_!H|=9#sVG@92YYy#GJleVV*5m+RV@IdCJ6VS+S% z)2_Z4(0_Arg6k7&_}{cnjnj)3JUXdJtUX*6#P37B^4k%kow}(0*ov9vFLo8^7CvIFkOeIwl zta7j*UqYb66i~N$Gy3|&nowEKkAAnm=27m)Qb#f8%$mq;fQ075*T0;!%y`zMwJ8m7 zZWx%IQJf^UM?4@GXu>LZ0$vz)QC{t=qPn8_)a8f5u5bf*H>YxU!Es(aFN|zd&bM2# zv9GPc?&S}>KBby!>fDh>>Pl8uFJ4maz-Dz|t=cvRc2X=a*G(2UKS1iV!lQB0!BSaKB?dC|UyaD6Y@ zZ!6=~Jp~uf90J`9NV)0Z$0kfL=Qe7@7m1=eXyWDmt*n^t^p0_+rch(W<9!mqOt!(b zR`_1WA&T%>2;~}98oaOmbebTC`==@K? z?EyRtogtwBBr!OxTTaojCK)Y2V*{!kNUMlR;bW{}T>hCLE+V!}%X0bEHyMe!S_(rX zl7B~fg7V2CS2g7ZiJH|8cl>tI!rRcNc248%z&$w-VLcSM?htf$2*yDO`kFp=3;`QD z%Z76{RF^(h3=SK{jQ=}X;$yr}&}#?;{73o?ab4g%ep4dGta4zXSp5usSDyE0(9e>E z9&ol8?=FM#+4RSA$6n1p_7~z$y&=TS9~Rh@_&D7)+3ozhST|prJz^P zL=f5M@DuW2_4Qf(Mu6FYdaB=B9k>W-7IuaSn#4YjTDW=Q6qf3GeA_o|IG1+@2vpHbhqE8oen=qwV zjOdFIwAx!%Hml`BZ7rPsCXo4XFaD zM=C&smI_?d`|tOREu=i-*DvhC@!~@uU;T6frD*H0Yx@MMp>0MP&)z$;gXUqQ0nmrn zT`pP^xP6F^^jXxG%7Q7(59Xh+DJ2q-3WsAcy$AEAE6Rh(H@6;h?n_gyy5g;S?#VOC zBLHyY15#Jc&F(_Jp*C|+3@n*Iy3|dgRb49CwUx=!A3tj3r{lSk54W{gJU2n|aho7; zeZnGb%v0`VrDfg&sP}RZWmImA31C!Rc6s3s*{!N{OYC#mpZAVK9D^O(L< zqWK*j-Jk`E=!AFb3Pz5kQf(a3br?MDl3y=G3C}A>)`R%$2KC&n3R-$mnEhB)bv>2! ziPLGZV^PFc@31St+N9>G=k>9iJokd}WU&rG)Ky9*EJ}W}YL%5^QSrgq zAsCl)7%mwQGi5-XN0OMs-s0kO@fjJ_m?(NaFaVc*!=$B=WD3Kfa_yoF|5p<_mli!z z2NBak^qjCKg(t)4yUG$bVoh^WdtG;;F_qIh&L%0cU6Di^=T$;gfrW#y2*<<1Aqfe? z916vg{YR#Sp(nz?XM~_Xi76q837i!nx`YTv(k4k~K=2#?^*^|bZ#0^4B*%khr>br zxoJtMk~7u66QV$v@h&A9j>Y~CbJB31>RafEHPTZi@OLe^<@l~Rv1Jd!xQW4@AVm` zi;&65A8m1nnWT4~laN3ijsC`cX*#E$Z|UolkIajyIX2B=3j4rtG*g6-3Nwd7*tJ-F$a#}+E-j^*{3Ao zqsI!eI853U2VU=zw*sCoG1vjz>*Rk4)UIcJW|u~$pvDzrN={{~yl z);b==g)mgw4}z#If}nfV`1i(VQ(j8iu?glZp|qE1Q3eXUdb8aw##w?SsF>UyTwMz$ zXqn~{(xU2r9#FOG_i2#IfJ?v!zNIdkJNxNdDrm4Dv|vcD=^i6JxOsPD-~++v@x8DH zbj6C&#uibFmn>H1s{kWYkY9nkyRjVXz}9>R_QD72drD4U$rPnP<(^p{$vf{)^cb5s z;~D2WcaHFwOsx=*uRSW+5$DO&x~WOvLmZU=U+mCz%cK&S)NmSN)#+^yObqvv5hQ3_!zdvfCR8*kRv>o{KImd}#r`_5p zKvWVpXi!z9bLm*HaW6IC^(Ro$WcahG8dIv$gft0Tqw&x|cMt%kE6>dVfK8hTJj$n^ z^tvHH0Kw#t4zC&xhq&O3j5xSusBXeev7rj9IQUD#r0#CM36>q~d4U(}D4e`R3FcmU za+Y63MGQ`Xt>S5*WhPo*fwYx8tSYMOB}d;8Uti7(RmM|Y$o zh!yO0B3S|tyyK6 ze>?9HZKErs@IGG7ybBA9r* zJF|1?rx6j}^Zm_gd4H4Uw!pe+%dy}#yT?cLiV9-#zy_MHRj{g@Fl)*;U3%ZRW*FyJ zhtk_81Jtg80l?HAhOvqW%G^&77|)y6yMwN3N}j_csrgs;K*#<7MjTl+9mWj!{9-)6 zC^gE@us_3I3*krDa6f~0VFWKEe1@W?2#dY*|H1xpf}S%_TVZaJR+O;J3m|U+0CU4P>IgXVJ*u5n6 z&{KBym?%z$mtkJH0~R@b!^6!X9yhF?%6()p1-~-fcf4fY9vS(uF8MU>+pr-~OiXN! zS1$y=QOV3eRh~RJod_OTeFQdJ#yxaIjJSHJeJ8v^ZxHN6(8jj*T9FW7i4>{<=GVo= z$hH@|>&=S5Z&fAhf%%=|19XzgK)v`h=LWvd)I@SnN~=*IWyQqU${I9?<$0+c=t>7< zCPt4`8wN4Zm zB2@z_#7_P%2YN*3>*}DXPzR+K@+4J3JS{Ww!7M1^kKN z5M^H!*2mhK!lJ=6M?**!g-1rA-y+dHQ5amL2C`$E*aS(G;6n`$!CCt2KM)=FOHKmJ z37nLGN1?OGs)x%$cB)_XI-&pw8c1100|HYQAF{j;sElZq;S%!sRZ4hZ<#*(-ER7f> z{a585daVB|F(N8FK_f;RoxpI@$e}E08vGyp21%mLIer7wMplvZ;oz(R`N2cbc^{Tc z|8L!DlIq7U+U)Fw%GXEM-zuRel2XwqNCpOkz9)Wnq!wD_c2smY7W5t>Tys-ph*P9C zS3Is9?8=s-(Y{cv=QgDoCfZb9W~LM$2RzIbla|W;#iZ9I6?M6cN1^-=ZP;}SM9JgO zX?Fz2)MG{G&w9aHe!#2ThadFmL}Fb%ktGt#sP2zvCSpJpbon_(#YvKnG{zeiR49O( zKKi};L2zGyFt4$!$=|6#(koBCKXwNdem~4dgX`NSV<!2h8m0f9RN^%R>9oZ!{j0leEBNc?)9Slc^Db0u2jvbv(?$;vg? z+w*2rpR%py;2YX1p02^FTw#JWPhDT>^5^^Y%Qu>YEO9D)QXh~sn%iA)zF1}aqIo?N z=7R9)RBXbAg@|A+3{n{yuW7AGZZupUS76n~IR<;9VFhvfQmd@@nG*G8E9-AF4x%pc_ z0+T>q`j`YLmw4YfILuoCUp9*;emL<5dajXj&fnjj@ybA`zwVGseCH6bLiza~3PHUI z@9@1(Q`gt2voOQRPsi%MWiG3jjA+#HIdO=$q!aF{w=oL#3&~VFki6b_gWdo%ceF=h z?E;gbtINSapndjYT|pcve!JHq|M*##E`fK`pAF2yu#}X5#ia27QYqD`sHiUr0sy`O znOon0`nLgHZ4quV@nmjIz+UnZQ&cI5!JC`_nLG>{w4X3BSU8R}*|O!VQD)Vn#z5ne z41YSf^1m1-sdCLAl@N={<@3*EbDk)(N+!s(dAypGt!f(?-)VCk6E26zJgX&gBCus@ z1p2x*I-m|Vr_oq%qLH^2#&85yz*_i(3+Ol+2ZmII)<%R2(LFws^n{oL4K4-{FqOn1 zwD{Bf!ma*gq=a%6?6pE+JX6X`sq^;Y#1b^~*P8}62N2Gm2yjf_(h?KVKELk8L5XxJ zj^O~0%6WKGdRsL1D13b&q+b0QrMlii*DWY^o#uBr7u{HcsaT zF)kxl8+L^R2c2Ba2M#@ltf=sAFNnO*PSupnvWSNorThJ9Q*cK_1m7VuVRF#`2%)>PrW*?sS# zHe<<~c0)wY;A=;DYhKSA{aXpTfbBW0O)^ zR4&yzy1Z78(zk*d)QwfVZ5t_G8j<6Ny+V)w_Pw_AXQ~Q zRIsRdpZ990tB9URTQm@=j(aga+d;BY3?jWw2(~VGr_=36=-G4N8}8IS_YH#+li^Sv zwTEN?1dv4Nfyt)gZ^Wvu+%ut5d1G ze~$YgPVj1jIT;KV0grjuT=CigUQ)mD7DI6m+)=3(7 zP}Y1$vMrYLR|WP$^30%4VvxYVe~mU3H~E)8f1Uo6fAP|I|A4B{rYL+JbY(Oa?mA!@q5*NJgMNxeTM^?Dicc_@37cf4_*#CB1K0JU ziID)PU6*3ecZ_qAN|3~je$Wv`>MBh@n3$k5DPT@{Vp4E4m=$^_j75CtzyAoY{0K%0 zj4NDMW9$Jq4+Sik5q!$=8IO6rV0Hg$Sa@{udKoLw`HH|Jc;a=IO+2`djqBSR3O}ehf=I0LGVn(CDV$ zF(KX2sCS`DNLAHHumDUAu2Q3pseqj`Xfhfft>BhHw3O+WSl>ONLiXK|)Df--fwi=cV>Yc^35ilbME&IY9SJh8jmi^D4Ofb` zEn{6J>;8J-{b08iGRP%5>S?@b<0Vu0*IkBQj&QE;P|3+s4Hn1fu0QONq=!=pH}jslit6j-4)0B;n+q-Krut?P@I*mL`+jN7y=js_bqj^Pz;6vjF&)=>Fq z>mk%ko!O^g1>-Ig)EN)G-yPEOyEs|775W~s7AnK^+lHyPWXGqW%N82pGGL^EKh(r_B2q_ zxK#MWO2Som^SR1GNZ2A#Q99w?-e{xl$k^TU`x+LW zKlRM*x8oguIRTg?g>>oCj1$dmpfVt-7MWYOGC`#j0w%_WSW4vRt_XoCp;AInN_Gef zR0d8R03FLvn0P$f!mi20WM*dSDl>#cQ2unG2GHTUipUR+)*F%>!DS@E!5eTby3ZeU zC&n$B{+AQ2E0DC*VkQ%$D{5`vi()W7>SMvL3rSmD6hcy#aFhlO1n?__T!EB@93B$m zLJ}6xey}Hg35(9K;0O8(MbYyERUl?X#KssSyjn8WrGnT+_hBurk%lmje=9hH93DtjY9LT?>A> zHU8yQ4($ag9@-eD<&@j+bkFZ_FFxLXKUr@vcAQ1TM#F;3ue$R);RAC5OD?02z;f#U zol~a<8VT=fRO0eE<4l!fAXk-h|J|=3x(R~^3l8us0d*5<(0$oqhe;G}0-|XHmPONh{)@?AKp#FK=oG1qIdDfJ$hsr_5JJWjrGe-(p`muxyu<|JYT?zs}@{b zQ&`t^qPxtQ+j@EH#R^mK>Xt*F-j03Ek4EXifS9v;d8>sdef=ws%qE zuIQKJ3;$8yp%!-HGzItyE~HEdxp>gg;^McCCZVSUOS2%RSR)W?r!FtAed!7*AGuEe z;T()t>;meH4IHk#vFGQE8ld>N zp;&PGS*?7u#{;np$M&Hft1u^4hDFxe&2h6xmmf%Y4=nHre0q8UkP``PExac7$~h3j&VCOx9<_sNfI%*ZY-WrSVxu;RQJ1${URn=`6sA=vYo-7Bh8+IXV;KXFe+1;mR`p zJHOEnf1Kn57n6~ShP`6<({shKNKEPh5XQkr1lJ1;PLzNMvluN*E-fO{aYV@Fz6Tg- zd1+SZ&h&8|17flGlnj%l(52xVExP}@vC+BYmK>4@xHPH`goG=0$b;B}@Z8xELz#Jt ziVuXH%smsn>q3NdzPtliZL4C18*;n!p%NLiI6&kJUOiD4uL8NG?s&ob?4dgoU zvQwm2?tM4Ht_Wkn$S337!f^F#IIx;Id4BSKDrfxGC9#6b_dEcSw*e+~R<#K4&c5-k z1$6DX8{T?CYc3{3Tv*KIDet|yda{20u|3QBh^5{s(3RF=yl<%}`IZ!_9qkea@FbHH zFe8t_&&bmE6T57tawrk*Vr!kJ2D(*lU_LKe)(e(xQ(PAhJ$P|tZKx^Qy9IktxnMu) zG6ipHGjwub_f4v*%v^*XuK^tU8}h&Wm*{Hx+;O5cEQYJpL$iy6 z!ML~LWi5wgt4o76p8oW&>>V9f^XP%E6NEw^{Kl}GF|`^%U3Cr%__w7F_T|T&{NB2$ zIp6C0ZgVb%BrWP-Fj%m#)Zo!TJ0MqZOICdu_SGsj^0d+HMMZZc_*_9lP~IR=l^Z09 z6J%LEgZ5a@3ET>`g4-I|@biG13Azu96*lBVgOWhs)VqjX$3LuGruNY!t0Jvs{z|igUsr*kI)JZM2 zZ@=m@bZfT^KPCo(3zqap$HeFh?KCX3y?Mg7P@DOEv|QAjiBtb2{D2H8@`cyK0BsqI4z7>lYNP!iK4Xz!Kux3$1oNWzen2v!<5-B=gSF7Dfq;UKY!Mwzri1} z@)*>KX|!8PV^t+Ni7SrP175m6hSseZYCW*V)|TwS7%K{ccVoN?w+mpql@Jwvzj0ub zfb@jkkyKrc!(fd7^$AAeb#)xjhC+N=TEI69l`}SNS0^+a+_3YbU=SJMD=#mw6_)=P zaqyxmXm3t)p~TlzwekfZXEQpH*c6_xZ<;-hN_Ez4Q($!I+drPLkqNE4-wL_ZfO7)6OPsuE-6M*GV zMJRwzuua%GeDm>u%Tk=lFgJzN=eA-_YPFBI1@s+~8r3v}lNsrv5LJE<*g5`im zaBPUXYjR+bAQ$Xtz3I=^iExhs5S4 zumj;xF&$^8)aB-VYg3(UrH+MpEd84yLa}EgNGMb(I(8rvV}1GQ=GN5jVus}TYs1*v zW8S}6lOSnPwcc0+>Os1ar^;Mf;X!e+b8qr;BOk_ZbYafu+p*@~4yLFJFKFRtA5vFu z$1_%fNUX0Fv+%HypN_Z(Q#n~oV=-E8YMk~SK%&jzoptV2tS<=|UFH#6VCQ`!)IU9O zdv7TysIJ)&%seH9&lW-L&wz=+ z*d+pJm_r*F7m@}zuLCy;#>J8+Wrr(nt(G7{HN-ebT)28Dv@T{vU9@FAbT6rjg2AxZ zM6;=~+0i@bc)v;qm=-WEf%O1&A~hkYl1Qb5air&^j-Y$srJ67>E)+y#m_GR!L6;x_ z&%p@31PNvySmRvySIUDQryQ7L7Xn97#(N{nW(^utO&VBPnQI9+O5rXsfY2lq&v;cjN~HYfNZ}GM#rc_RYzB^rK}A(8)_dUo2C0z zk-tfUc9r+~j)r6v9S`4q-ex{r(h|D!TB=<*OsXokRb2Gq+;I>S`x8X(B(dQ$fX5*a zGMR=$UoTX};mjQMK^$j+KOBX-L(w=<6ZsTjrTgtkS(DCEDUa@0a-&r4n*b)SH$1NX z;C~;Dp$9NWTn1%D;Ae5eyexZE2p1YP4|r7xpXk+l1D<*Rz||FW52Y8^Z|a^em=jM4=Gxhe&+Dd?AHFYtZ%L!F z!}V28Y~tfBK8=_5dd!ZkO@}_6*HIvZaG}rYPjm0sz#ZHaO z046M614V84)#1AuCDnhNnjDy7! z$J_DjpA@UG{Jl^dUvojDkK3F>M64uDyitOq2PE1CCf5W49 zWZebi>TrnqTVc`2(FB&}Gx5tl=$e#fG{k4@odyLDnvysPQLh!PHB?eE7@7U&vAfPtUW_TBqGaeT73H=&VXx5-&Cvi%4`B&_1>$}{hIN+;eEZ|$Qv+&Wx-TQc zX(|p57K+@Wy6b!!#knz2WzOo-7|N%+gb0?}A=Col{(56xbTS1y;5S4hvunan9DwD} zRzXoW1$))~9k#DIIm~%k#Y|;XP1~kVw?=*YZ4>79#gnv2<#>SowRoE#)((SDFR0QO z`H3l%<#K%8k0o_K7@ob@;AHxE{!HBF&4G~H{l1GFV1cBW zfRnju!4A>(h<8V7i>ZhwbmjQ1`cC?Ha3$l{eHzY5))3+zZkNLXTV2}}$l zAV(yQ;V8>QY9eAtD3NN228iGPiq4hLSqux3vXF!YY$W+_L8`*w&^RV$C0GAwbmYh? z?IQXj8=K{es0UO-N)Zf^3ojK3g;4(xlXKmP)*XX^BW5K!C>!2_32Rcxv_Q23zduAD zsmvN0dEG5rJ)_=w)ldC_e}RKdn42z=?(fQ2GXqv1kMer8UKp=~K%8&l&6Zfn5uy(b zPXQ!3s=5m^={tyOSJtX%xxcFExf7!LRK%K!jEqy$5ki&2`kDEK%%`$-$wIiaZ>bY1g#vt9rCMV!Rsd?f|t8j0Sk_CXn-R)yWW|L&)vI-RCOyd1s=M`!t%9p%pPaPa?z#YA?FoVhG zI+%ik%_>AdvdaZr1KTUf>Nd0L+gY!uZ2#oDZn-L>L1oQ>^S_=OH`X;}U5aO3Pe&|_ zov;{pKn$fwvQVN#ObopC7U3(YbN7H7*azD2BdmN+HcCsQ^FK>Y5Vxs`iF`Wz#L&gx z)lUrQF{$khoD0>*h{lXM{%pL`^{R=~Cb{d{hM5F8%*T{^U^QYv2pZtU>Vvt4B#_ld zn@51T03*#Dh+w!KCIVboaZs7@Iz&I z69iatEq_X#|HPKZcTz>h{Is-HXpVOZ7Od`y2n9f}>GJ?eK()V$Mr>unupfhb%sxyV zBK2oMyjv?#zKi-kW#JUI-Str6x>!p_;eEVE?f{Y3c}`F^t5V$@ml_t!F|j4;VS~5T zEJgP1yx=O_zOsr~b^Q8WeJdosYaSCr&vC+~PO>}u_OfGqtBMn$4o{w4P<&ohFkLJa z9cN2~n3L^?JsOY{P<=srbZ3CB!a7>Q#fu78r7!9F|Ahofr%Png>wuA=7rq$tvv$zH z>|%dfOSTMdR3g-ES!|AuD>a41Tr$j0^YiMu3cvQ55~VQhWc(`-%oLxtbRnvZz~h%WhNNLpHA z=_!Z%_N^u1QtD@Ihi*6ea(3%?y2%|(gLtcY6V5f#WUj=OFKj)r%)DeSyAP5@+>NAZ z-8vN&G`L0f2>o%cB^ECcZFL-1(f~F2L9%#vhKHvH<__IM7_p!Z4~XZA6`_8JpzQi$ zmk$e1fId(jQq?t+>@8r5(isb2AMguZAc<)%BHoM#p$WibWI*+`+pH*C9d7sj7MXhv z-TU;@Kb!0N^PDNBR{NLKHOMFeKV0N;9fza4YW1fwDd`@2{a?vYfdSb@xc9%J zT`Q{_I6SrbGrQYjiGrM@4pcty*&={((Hf$GW6`-WX!j^cL~6qph}`uvaxzB@imo0) zDZ1oB=UDKA2nD1qIICk=C_*~M7yTiravm<{V`GbO<=iFE+$^;91$+ndqUJ#QmP!xvCr(+lrE7)RG+dGRL8>6c zpoCoDaZZG1X1jv+=R^y+o)|13Hf8RljFY0 zo$KpRc1oz{I?b8Y5=3|4Jub~w;1w+=OU%Ki+FhZxqB;aHI@slq+J&ec+5t`a=TAhR zr%iKmaZoEoM#giw5YJy3uZP}J`vmb>+|kjpxod2Rsb2F!F|sKnG7a*jkg$uUQ0%EPeQ3kBreNl~Vtm3R81B@00pLaPDEvWXO5VIYGj}=& zjoKQ)bAjU$8=#GgG%LT$-8^*dM`3->P#s=iFEms7h7HteOWiO7!uS$3i(lc}VrIpg zTCjhd{94-(7W`9TcT)*G413jf0BA~z$YpqJ!m4?lYQr46ht7Q9k=GAYrhHEi3b)d1Pg|;gz>UW#lZRv|TgP}(4IrS8#_YO@K(o~ms zTTF|E0sIZsaRWf>4I9Rar|~_vq1`vEQnEN)iiTM< z3qu=)Aj%Y6cY(#J1l`heDW(ujYf=r-H&P4f=Wz{wSBSZ-^s&N0EyS`=P8P0T#4#HI zDlVy_dJ&9Qu1R&5UdXkVUYBJM29qvns>H6~eN|neVyMXQAXMmog4WQh{9jC1EB^Nq z=`n%76LLwlRK*b>y=lMYM;jwx-;MMYGpMiR2yH;P9HpX+d5iNA%U#=oUEE4dP}Xl4 z*z!oV*1?2GnFe1p&SBnzw+!uMF3bDe!Sl_{*hS>~e0flJtyCrPo9uSoH}{(LDow;d z`s9A6!n%vMXtjlSEKX;+ z<=(GfVb83ia>pH6)lSMQ@5RZU#@LA>bd`ACrv+#GBsj?AOkmVFIH8igAXk}q=9alV zI-WJ^Gh@}NI)^NwEsvS(jS4g_vYsZ}S}CrIcAh+UtBSL}DAz@^)mO^nKG1(2z&rk| zM^jJLYY=kjN)u(HOge(%=+X@$#4Ht79!bEQOvCrzkpa@{M5GW53;rTdJg_iy#3u>$ z<1W{aa$PlP@3SbMWq53<5Yn(zif&mh1;^xxVf7275OcN^q+Jm0+2=&-sfD8TpjQma zWc3=HVli9*12tviuKFeLDT7@uF&OZ}FJL_L$DkHHCb*09P1@OD2woa$f`kf*aBnMk z;JP4F36XZqZ8i7+h`H=!Pe=P=qI)YHfVZ-84K%M-#GO^r`TeeK5|dA69Z6uKPi-TX z%Dpw8(gnU}f-q)Jdd%X66S0;J>tk*`LofF=b2hwmTf{BCTKoWo<69~o&g;n&_UEnQ z*H35B8yUOx4Aw3Y?pWHEmkzwyT@ln@Rm2vUV(4G(2-dkR0>JEotz=JF*f-VGGM{Yt z#L}W^Ay-E((Hb>!y02_Zn*X6O4O>D<)fpR>?pUI3M58woB?KSw`aJMIatO=lE;t?A zWR<;pI~HC#dNsy!#mp7vy1SP&lM3_VrP7jiOjbIAsc&Tw_V#D(gR{M{Uz*%neq6}h^gjdjsSv3TD`peeCnNTN{n+W1`y zephsy3#p9!l9kFgKppg;h}5$H>iPh{#gQa{Zd@WCE0?fs$pIjnB#2-rHMsK7&TFEW z3)+wmf3*2VOuWhxuX!LTi=h&V0fr?X7?$j^XpNW`mWZIp)Xp_b%Qf5Yx4J;%UN5_n z`k~J&zx_5(^grykDf>WJEw+SB-bBJ-N_stWk#S* z<}J)65Hh(GUkOX1RW5<;-8rZk0n4hTy`#p(S@SLq6kVl5I7RLeCJ3-vMd)&#Td!Gd z-gi!LP{VON*EH#^MbDN|@MdV{ZFpI6lt}|iBa?2eDF4dCGyQ2gN zek(29fwB_IH!rMiy_~V0*l}ZKY8^Wi<5V`{a$H7W zLK#el&J_Kul*`PCaVae`mrLxO$kLBqE;9{f<|6Vnz?;{#NKjeYUzme6Zxmi+>esMb z2#-ZD(ne*Xqj9-t3&|H9VFiNSmo3=~vPJ8KV}dnf1t%S4j+mcV+fcp#sf>B6R)rst zYz<2UYYUm@8p07N0F;benO_h%`55ObC^h-=>`-?!2O}E`U~m|~?M13ZIxgEx>8_UI z$sXJi??u)q*4@U`fvYXR-M&^A5L%y>s~%zpl-+!ic)aYHW&%9Ywm?MSZoI*9Uo=Y@m0JETn?v*7IuCZEbm znm)yb7>jMn%3=~BiR>x=Kla`P$dBW`@9nwgfA786#SMZW2oSt!*)ornEjf}LSy3D? zDqBt>r=q;)_Z&aF$}g#Rgv+XDSDeGjvnsAwRkrN7th^E;l`Y!3WrBw&$`UB?Sdat( z5a%A);fPJQlKtw@6Nw_dU|GhdV0FQ{X1g=uxujVnVIl^(sYu) z9UT4hZ}A)%n3wBH_?_SRLKOZA@u7h#_# zy$!0&Fg+2wlj&K^8GMuY8;}aW{=z`+`7hme+hr`JZ++w=d2_M8)AgITLy5V^@Ppg= zo^QqAT|bt4k%dVVpj+DiCAK4UqGyrEc-GY8`U8m4h1Am&D;9;}&hHZRZ;jmeE!~z;=pn zVS`Gm`s_l!W93%w2;pXzqHxFTzF5sgi5ZF&Af?8+P+Oq5Ain}|IgcU-6c-JO3+y2S zwsvaIL_tuiR=U|W9WG*s3%Gnmm` zlrctj@)+-=_>r-?ChYN}GrP2X-SdxjV~O^S^>djcYjV1K-K@LDOX*>bPD~A_ncIzr zCYe^VJFYwDdhT={;Cf#1hxuBd`GXnH6tCS^uuy#OBU9Tf-DY*Cy@LtjKFzZByU6AU zdDLH+5vs?dG}sqBr9JU;t!Iv1DnR`BhyJ&*{^j%c!)t$!7W(fj>T0vtoWMNf3&`4h zF3uM}cl>R4ee->jldG$2X|DhKvxo1g6`WsaJLbmbDlg0y-Ba}; zqZMhXqn~Lh`|(FUJM_p?>Vq%Hxr`~>Ys>%eSG;!NeW)6WW3=P8?LE>r{Hf2-c`|bP z2h=AYc-#9MznmM4Za2-esx{KT&3&@&rrXna){FowTcG^J!gDq*%{o~*79S%;f~hL{AOQw@++uZujo#^ zVCH7d8~IZJm6Km~%Fii%@T|JCv`X=t#EWlD-*RK18QUXej3udF0bD>2z&v_p6Qj9o zhs$j(S?=R4jiBDPC9w=Eb*!bx)sLqA_R)gYH(lBK4JTIK&gg#U*0iMEtovv`U5J|L zbIFO+)n3;AO?kTU?tUGr<1XEcG0U&r43Kd{OP%p7?e8v(=Krp}HFx^|(LSF$l^fjO z`ha_H0NXJLwei-x*{FTReDS=pzCNe4XaD&0-&D74`{ZaG-+x=PK74QJ+Xs@w9Hl=r z>9YvSrjO;E=3|wSPpG3!)KBr9qZQJA({Jt_dgz4@ z-gUmRu>3BJ%43HK-$yDDb_eEU(pjiHFX>70B=hdmPdnvjzJ7^20(sw}Vr^;mUgb3p zm{E)tSbRH+mzzquQKDWV&`SqR5q{RD*#9=a7520wJ97fLIyNMn?`&VRqw*lqPn{I3ZYv@nW9?-&%NF@VQ* zsGyjtj@eGJ?vpgPs)N#%NAZz z6d)oX#9nM@j6rJmQZW@EvTy8UijP~h3N-c&qm2ti`G?(}kv!Au44npgohSX+6lZC| z3d&$5r5(8&ui?Va?*=jKH=K9&=y=*?MxP*iB4b;jX%*J0Dy*-(O5;5WPyVr;&BizX z@Pl`WX2WWb&iDL3_@z4mIqxz8@4aafy{!PLwjIZ?2eikNeC`YNO(T!p_2+-S?!4dp z>nB7O<~<%DC$!AHVpYNP!fI)5rB-=)KF&=omh7rm&>YQ+l=atQ`O%h<8wxbL z=xcVVWk7(^ZM?i0QO$H~P0d&_I9ImcNjQ3qX>zXM6^!QZ<}`f)WFDJJk3P7j_UclANj=Il4{(Uw&OdKYP>_W z(s2N?m6Un9twqn;HIFAK$a*nuK(P>VkmvV3LF* z2(fIXuWV=RK)VPFx|wQp3e`nJRoy5##R7)tP>qJ2CMwUeaEm-&w1-qkj20O0NJFhT zYa6I7`gs8KG}N4F%!Q#*6a!-HS=DaWKK9-Zt$Uwq+iEqqr3wI|#KgnoM##&oyxV$Zu6Lq zq*=S#RMtV}leCtlX$a;7D1V7MJ2JfRe`){nh_EB2eb%lSh>&8172p=#@eWSXI5b_GvufyO)H;tbv1>xfr zY+HQ%0|zn^eXn^KC5wyieD_ZqG3w|^^iBqfeHx<#Z}dd$I*%p!!k6P_?a>2Y{i;|Q z?1WcS_kViz?;g-h^|P^M-ks?BxE{rUz2eQ6&s0~+FJG{8)3qTaB^*fq=N7-{KKeUq z1d#KKM;S}#XFqeGzB2f8p=aHPT+Rsd>#0Jq{$(fs#NVF#`aeIv_3ihL{JN^YefT}$ z{kc-I*$(5m{ORDa$}{b!o1btOp1NcEh#sV9c_g>6IxZTTzN~xE8Rv9s)_$yRsFx$G z?js}^GWmZqIg6j*7d3nhE&tihR{S#+S8LQZ8MXFS^uJgkrWhCkN@-RriCsO>bIggZ zi>xWiTX62tY=8=JmeKuuKK7>nVE#YNKwrPel6dr07xoygAmWlr{ee+f4SSNPvNQJk z`_j0aPa>-vhdf45&ZR&NMr#-2Cr%n5Sb^#oTAv6)BY}qa3%7F^1Q|1N)LzbZ25eUb4=%mn;8i+Rz{O$I<@Su9fs% z=?z9%D3f$dc0Jw^c z8f|yXw8L)<3@^Y^`qHbN2k!jdrv7a6hyP*iF4GBb$1=@7YFb$v|P~44I20ePQ0PzV(+sb?cFg&9UagESskC>1v5X~4@%yy5Qv%%6dJbnJ$pep)+{4X|sj)=91!x$|~u zN8FR?T}O^!R8xA&>kPc_$*xTo@mbV4`+>|aju={dNV*;`;k#Y+*Pz^KaWZZo>q0-- z0x@MLl&PJZx1DX2Z4+H}z(TXjazWx2>e1c1C4d&8w#Y_irt-|hgT_!g!{j4&T3AQu z=P*ez!^E|ec){Z0io19%ETTy1&!Br<>WTv~(w@A_`Lm${~hi)m_d!Z_7gCAx;P@(v#BQ!C7~kYFLC z$_QJ~!g$%Kw&wayFUF-)3;p)_Rw-Uc+Ww3BOBZJB zr+;7de-pyT^nCgiw-lYdHEgN-qENqu&X0ll_@rg~PvcAN*~dS7fBlQUynVBki|#4x zjc<}^rC^8W@<#AnVZeWR;b7i-b=#)Fx})!*+inL7J6vU zX+`@s(@3HgknK62DEmzG%Y`NX!y{oZ5*6T}jNzMQ1ep!k5?Ws9Olj=!5!syNg6u6j z0Z%YN!U@5}z0#{f9deAc3e9J3(AQRMOKWYOf2D=MXC_aSwd*UXqk?%R>b8Xev?5MF zv1@PJSTuKURcWq2)f~QHn|A_{9UZk8Q->f_J&p{%XhYT#+y@B6OTZO6h$oED-ol$u z=<6`rlGI<;41Y#5ypu|AE2F%uoI*p{eG#8~sCEm-u{$|Ebv8HQN6ua+DCmA`*fG-~ zj#OCT^dnD|k7Ec;JROr%8{@x>8eW6j`Kjik@*2Qtf-!_QYGrDEVw%-NOYx{1FBqY- z)VzLW(xGiTTu09@_S{XswJ|@Jb zkwi;*Ke{m3^3GMh1GVeRp3!*5*;b!7c0>hzEVA`NSkkK2?qsh2miX1%-#uHu@s`t8 zrEhU_oLDy}uq)%plzT37#}k;pd8RT@y9~taQvYq+zfjeEh5Lis7;5)|Uw6PBD+DFm z2!{*4w+U*@rjp?e=C#DN`y$VTW2PISq(I`^$^Ex-rY`Vzh#_yUzyJ+&e0-{Vcx?okbKpRdCeLE0pClkna zklhZ(fg2dDw=hBVJH@0MVRWJ0SE_&f?)R-@iFY6UvPjrO41T)KWx3?&?uU4kFM^CzAZ@5wN;v%$;6wb4|@z zQS^8N1yN#@Ci0eP6k`>aj1-Wsr7YaiBA}qefK=ptGnT0$?|_)4iVi3dV8Yl>vm>Uc zWnhDMj>|CCZJK79oNQw45ksAVr^lHO)nOX|lO3$B3n)X6Y>Mr0Hgq?Huo( zCZ}F;lT4b92pE;6vvSg!xFtb5Q>EV;EgJV~|LXI;6rzSw?7D`|Q(xCh{uo}24|@{w z;S`>H#cq!1x?E``bN?eJZbUoscHBMO$He}2`q%9k3B=7VenXnI863-h(@K)V4MThC z(f8i?Enev=MS?e5Id%JicLe=o??q+urx>nw;-rvyHf0{wo>r~K;jyQmevICIk)m_5 zy8VN5ufC^}B=>`%w^1?X$FN>pXtYXam-PIr%Q@?8(@^KbdhiYX^o(+ze@YEZ-T9N+ z%)kE3bMDy4)D3Bv+!6cMF7%y1>+y``de3`}zSA%K-M;n{|8>*w+#}A89LBSpQlgb_ z2$qYD;J9^re7^njrPhg^h5lxrI}*j(IM4ujKrOWVr0!nuBjaV9#~z0^@S>mBPBLAc zPXe*ni9qKp=xr(ZIjtK0JR%tP-PICvTCi(sLoYr2nw7$4)B!d-8TWLFK!L_2uXCSdH zzEPK9gOyGs?2#OkM~?T-pp!6Q0bf#9ItkJ#{7_b;DlIMbAm#*3C@&_C8zCogOg9py z;|Qy!P)@WJ)S%n;oZr%qdJXsShO2+R4O2!H7!06C8T zxj4eiSy(b00WoOX*$Db#20N`5TEWubyw~VI+FDK?Py50zq=gmbjJB0N#I+z3>uLEP zgv(ohqFvi{_x$p%{nNTzs1C5=8;e4F8<@{w@C`IQe5KS^|F&T~hhMgt(-*(;r^~;t zUf8=?)pqCZ^!MlmeH(=8k$A`%i25DdA1xT30GYu;NcUpceq(qR`F8Joy&v@lJ9d`l{3 z77*#@*7S)Bw8eR-T4%9{dk$5D8Pl}p^~9L}&+qua#RbvYM!h6G3AH8Dn7SnYhfS`9 zk5VQ^@gkt@n>wfs@1NI78M_94%}iB0jyy>)SWMf~P}UGu?XhpJhfS>XkM6Q&X2zk| z25#O=3kZ=HK@kP0rbQEK2rDuHAZ6Y~xifJSrzskUfD=}g)LVc^o(`6$kL7u9**>5( zC*VV{2V)1ZY@{WYeNN($>E_G^pp7h+G?|?=$GiJoAR-hZ(XeAU%KEcJ+?JZH2I=OR zf`p~)KHIWHPUcxxd*V~%@ z*q6t5e@)$q(A}EA&VB8lxcg`0Tm_Tv#$NhoPMPKsJ@+}iQGa~XQ%`;oA&Qq*PdUB3 z>*p_=oft^tJ3#|j@`S451zO`JQJ#&0}@SQJ>YbU;18g740>u0jH@Q_mnfvh-p zq%*-kGLz-~`FLT(Erxl!7`dr!#JXjKkVaNu=FE7pIUA(LG0)V_fJt7BO>;_xD8Mk$ zt2(RdMx^pp^($`Bz7Hjh{jP^Zh#i?K2gk?Mu`I~CT__JlBh(?@=zt0nA`Ift_;Y}Z zW3Vy!8O+x(E`V9QqU?hPD8^{s#MDAq35-K9A@HJ@ee?)bWbMaC$MQx-4EczO#Mzxt zTxkK)Y&^0Ur6!~?!wzo&>JIk7AikgoOB!Gp;${&Q)(8$2W)-cp1(<6yk$YIAL6Xl7 zBcJPfx@R^)Iww5G{<~JF{Yk^rmEWghP=-plJOE%xchx2`NwUZgevG$yjFI~#l%1v^ z)t%n|^{;cgYCP>&Ysi?CNR0o^Ou#<}z| zV$IpCpA`8sw2Kld#zPdr;v=3}Iugr>-E|@>=a7#jjVmIIDK= zCg9{DHJM%i5Nsa&4R^v?EgUE(htQ%q_^I!maw2_U;qGa;)h=c-j6Bx1A$O$(mJ=1; zM}=Xcg2r4K%4mfzy25*cTA~0ZXfGyg8Wa-+P*GZrSCz4As4!MOG}C6tu^Bf6jJ#G5 z5W;GVF@+I?9|oWf@}PHfj%?&)-H$%XC!J+w!;l=3_7j{VUc)rw@LC>&8=hHx1V*<(QoM~Rae2r=})Ujt(vS_J)j&wDJ#oU=R^^54-wv+R2VL@pa_$UiBwF z|KbikG7m5UWP2=&e=E_f~iIJ!1p{wL#I?_%?xO>_M z_THcLjouc!xm~FV7bi*1SSu?}l%9U#(P5>YrXzJ$YYps6oH=t~BKB?@3Zom5_8M`0 zKUb)GQMp=+%F|1Mbrvqii7@v5CaEFu?|G%_^8BE8^5t#esb~6)`Dd+4?NdAjA0c{B$c3a!>4fs5d+cWO4l9bsEHfB1 z-~ltJpk<>YsA9As#5SXrc_B2+xiCzoea$)(=k({{{!9T&vQHlg5v4&w!3m?nBi4JE z>b4<#x;5X7H!~D%MLamCYtVWquh~gkCTJrMU&%sz3G~R+4=;?t3Mz{ZZnnxn`5H`FyBr&xC-Ns6^WcUmGqS4HJ7tHh>6ZXfU*-KJ=Z?UPk_AiKXM&Ky#jJyUFvwfTuIQcZn$yQ6fDp{ajQi z-5N#%yOF)!y;2z2T20F3n&sr01#lQBImMG|bDw}B1kf}Y1m(H6BB{HF4t;BI&)e>Z%9R@u%Z3t#bU9XIab$a* zMZhT|Y(1}e{)@cJGsU3s>Za$OTLnQ}V_m?oor#er?I?C(_DxO6JCs3oJ&5UQLw4j_ z9$0h!V;$c`uR~S;e}3XG`i;cef^hw2`sNl;?M^z;F8bCEdJq~%YQ)Ota&`{sbA)q+ zRfM5AgmOL9pd-xnBDjtA5FPL=<ByC^c%UyLO9D!el}m;i7)>&^jQ|9xsEh_Ol}p^EhOEouTa$9~BgF-r z$TLdEqpi3YmEZ^o^5q^Rt^_!GfTUBdQ&w)&0YG%-H9$xJ5&ujbWOGkq0S=6j(0Ro; zEBtr*6=^n2!%$M@(^K)fiT;$lYNmTT25S zDm=1}Au?JJsC2|+bS42U!VE&xj93bhj*3Ra2p;G4<;b&=s8*}RhtyOAAv9|j?(uyO z(w-aPO=tlG*!9B?D7}8{h?dFeJoYe-_9icT(PC$(nm@DM*P=W4(C%W@eixJEEy$-p za>uQu(xwAle{R+=zByIue@q>yJjYsMp;u)(>MkaWx7YWL-WLq+xjl9A19aFn6T@-) z{QS3_SD*h@A0`M8U7y!pnm5#dy-771H{a}eH*X2U9Rp!d8f<%3sovCz3w6&vS97#8 zEdP%MQTVrMGf>ev7^Bsqisx?px9wod+Z*=Qy~~wSrCxUPZS0h_SPdp<$lC4d;&8Hb zw!eM;mHx`i^96f($}xfyy3;<2@~%=V3C$7RWq85;R!hH`Nqc)*&?<&)AlD(>VV*_4 zTpt$^KB~T!z8nYIqMz#XaZy{04DCF^zAK5Ni~j4KhvbM}>6@sG`qIeb-Y)!PH`y(FP*^F1nUgtScak;?~fbkW_lBhrV)5%d4`2P zq(ZESq~+Ycv}A8fRDO4m4BX&Z<;{L;XiGCH57!MdNzF)!+g!@P=kP`U_?tc9LE zkLc0)Q_odekKa2S{{$9;|+R@}7RSBzSf zeAp=DTFXVZzHD3dMaP`^mTjF^Zs8O)KCk-nGavoRSHfTVzy}~<3ojjRbiES*$E)6|>ms2e+oF(dc}S+t>^VC}zWXnQr5zOn6YbC%mQHSb zy^WGpxf29W>acbmQ-^V%@V!k`;v!;V|9p;hMjlPPd>Tb?4xuJuD~QPzAfjWg06R&5 z62`p>lP%N}Rblc+E(6Mn>}Tv8Mhg})9Akb}rcg{omW1P%1py^Y?iu>%9L&H$mgHPQ zr#U*mon1-GG3t=T0!T9avgSIydu92QmwgeAiR~${e%b<{(UBB#g<1>XB;{HtY_uRYdvPp4h0ToHVW@d|+iV92{zn0TLg|sO z%&55-#k=n-M(dO=xX4>E$UEE!-;Wkx`Jf(F+QF`z&iwFUZQ`*B1`8oQULNj!uMRzsMBD$dUmD-nbSh<K8Qt z6b6=CIkU>g$pYv*mNb=vf+pVVufGMN^vprI^8A601o zuX;so*DW<>g!TZ^`be(m1qO*6%1Dq%prk|qpH!q%fIx|bzAkFQuHKGubp#v9gH6;% z?6{e&PvF@=bcr~Ra;P&WTEN;xDi%UB5hfBzX)p<_o>^xxib)2rB}HgQB=9tbPXxNr z58$W`Q!EFo%Qdvvh3y5`EX`rV&Vde9hU?=(-+?h@K!m~SEdi<$5<#qw#HO^BG%y0+ z(p&y)6zLa~ot#QM^`~(&hk9f|g}DOqH+d*Kr4J^3`SGOS4unQ&a~PLLJ)?kj_Q2Tk zO0j<>&J`M=V>Q4J$mqbjM{c(1`T6CQxk`NDmGa8eUlgn7{+s2#s?2<#?BW)cjKkR9 zYCZrfbhouhtC&Mc#mNQzuI3foBrG~!qf%I2=qoHv=iJ37a_+J+tz~8mj4+z>?^~E_ z{=&=08WaEFKL&kAp6aLn48jiD#)!N-y7~64Nq^rsl`3Hfkhc`W?@SO<4NsfZQ&R)a z9-m@{67V9E|K;JS*5=*IbGvUyqivjgD-uJGeCGZ89v7c!{nf7|BGz^k*Ap_qkKcPy z>*g53s}mx;Djd0M2*3K|UzMy@W0UEaWAuW}0Ms3TVB}l$oqShNVA2t&ECUGVQhg>V zSYcv#4uM>c zC?~L5MQ#j_b#QVrstKQnfD_>c^Z788v4r506;RTnoMg|!4~MdlMN+nRK#ob8pI)9^ zM}km3gslQk8d;AFj_{kUR)n=8LaiB)AdiI+K%+z9ki2Z;47$2%VwSK$XJcs9=*vly za!nQ{4Kx~?JO_5AM3s?PgWZh6K}$D1%b)ef)Dcg6gKaMLe{b0j?J^yEna^tHzVr(TDlPg2B3b^IF#JU-dF?;9&3lzkyRXPPr~Ez#umX;CW>1 zh~dG;5^vXQJ&&)w$4~#&OC`g!cJP@VKt^PrnW&qY2=*YSQ-=O-qD@k(8s@QYJI<8K z7oJmt<>wJwoBP6lcsTicADt{R3+{k?v5T*AufK0_Yhn#njOt3mJ~MZLjxfy!dv@iP ziItQ8_!pvY-gR5>h0U9bPZtYC#563Fc1G@R`-SbbX8%CF-dC!5p*`QQ)I6^Jp7&6gPw|Nc-L*x~sDTRpdbQ=>IFy0~KH8wT?|vaPz-td(=kmA-tt zX`1!LqS3hEq~VIuZqA!g?S$>ur);maWa!>YW{RvxP}{EH$eyOLxX0J>1n{! z6ZDLfYs5woz~N{K>?y>Kv9JI^guw=i3{w`EMutn#wd&0nYui@R9o7^?6c7}X9#kAy z$>1lMHL>o?iA>ovv>Y>9jKO{^CTR_!i@;XXQ7AUd8vNxYM0kM&h<7H|Osq;%Ns=GI zGuC*5*+OJtcA=KOBP`~IBHgaUrd^8Ne4lUR`n(`l48we}*)9((uQ+IrGtOp|C+2~` z_X}xtc391v7)%#V^ylu=My*@yv2@g_sIujT1;=g| z-Gr^#aNX)cDYy7yr8Ivm?*Ll#IzX1OR%7~P?#L@ADRhi-C+J7AVqG6~esvVJZomo;xOPy_GNd*nIk9ftKIl#?V8stMplRcNOi9~0C{OnM@SZb4pfv5S)lgm8Vz%hPt_>=9{dN^^{8 zb!G$4WFZ4F{PX~gWOhn-fC;i5@+k_)gw-MxK3FP3#UPK(%kB?UZ9YU5`SO08)$)7);n_35(R;s{ zw$>U!Xt~2twksLbTV?S{Az}0NLbnaO(Z&4U@wZsqZHKqmQ~$6(E`#I5VP>lKaRV)+ z^yIE~dgSiAg{5-E(EsHVmHi&#rOMceK*@evOZPBbqu{hnDH_h6!2CM_qdKJnH z!j}jo)|(4|c;eZ{Jx_nGGJNcdgSn@V4Kix5YK0NO4_^ktJDAsJk=HT@{Pw%V$TfymYHI94^a$53z~M^Sb-?gvRNJ>9n4xO3{3R2VccM`266`MVJz2#D0YkB z4Ejm1UWHMxt9LE=<{E+y+YNHYTe~xb-Nf zCopbOw;UUG9-xwoG`AcjRxZ@+942R+z{FL6VdVqQvI8-F!?kgSVfzgfp*)yvxY{zQ zf70|8-Du%_U#@zl&zXHOr9O5_lk^mZNR9`?L}6Qbvxc zVC#_seY#+KxawG5wyc!}*RH}ks_pUP)~wo!`iaR@3v7df9 zX9TB3dt-pXz$E52^pI^Rf9TB%T20NGbC?MO_(JiK$$y?Q7Xy72I>r`troK(JTiXwK z?cJM_a2IN0V>BtyGew0JM!eG6*aljH#J)hwUIww{+v|RI_hZJy=A1?DEjD znroFJ)!u{#(HQ-46BEN`riN|2IKWG$+BU+OR4nJCf8Px>4 zl*FPkEc*bM_0{4Dd!a{7=>V0EpsBEAIs&H|5Rq|<4az3+a$E`E!8!$vM$#<-B)miR z*~He6rr|s;#mc*eJQ7L;dQxXqQAfV5$#Mo+5xpHk4Ka}PGhMU2c;51E{MhFq_P#j* z%9j4nzJr;nxZI)VUO@PQN1=%ABTqE4)>`;&qdqQbw3+Ni@g~w!0L74fy2n`Ab%>nc6i@MX8t<9lH5-3_F`+NTpJq9r}~aK z)%N(hwhMmFE;u7Yh^Z9o%;u+bW4W#yLPDQsB!k4R&;DZdLi?u{=b}Alj~P3szFsIl z^SO$3VxhpY4E5m5pcAB$j6~aH#ERx+TO(D>bN{KinVy>~cB3)?i^-oePfoo$R#LmVGw zvK?WP6E#|S8M2B78zC&IX*f(2wdWMQef@lv)?t4sV@3tRoSrvpuUDSs0pwZc=aoL( zBrcS-T(t%`ZD@n-Vj478(qS`7f~DD1eB&I!HvL`IBY5J@gg96G(8Z^FeW|I*gEqvp z90o9RIm6E3gcpV<_J=eb-kT=Qc!Yu?zyw(o0V{e1YKHLT1f&eGL0yA#qG>KBfiMHB zD;IVui-S%@x{-9|Y;xtJ5I&CplZ$Mc4(msN2`n4lmDXj$AYj_1l5lVGG;w}a<0Gka zdV>$E4m~uZkT5VbxBzZIk-sdAV4;#E4T-HtZ+IHq=E!QhIVWtiYvB$x5nK;d>?^f0 zby}r}h+6o>C56PQ?Zw14iLQF!?|r}&+(#bx9um@Gx87hx1-unBWIseR}d zou!?73(dX(yEZVCE>`-}Iwov9$4Z*W5u*;AEa*mg$rlK)ksn^p=t!CuwP17M=SX%HZuO4gVU--Ii9bYg|GYTNFAUvH>DC9?= zm^_M5E0&2{)jRjZOSiqZdF;m9TA#i7cJKci88;C6Mb5z~0KNJ!MLVc`Z-Df`-Y6b` z#{du(YctSUV8cnzA%Q@ng7Tp)G4(Al;m&{;XR*R`f!S*AYwzDH_q(R>>!16a^Rf57 z*Z;M{|Koi~G5sMokS+pkK*7ocaHX)guzbr+r|I420X5U~;@K4Hips@{Cu)_&&vX8@ z+>GmKNB-y|X5Ah)9Ds@qP?7U8kg^w$(ilwJP+96wStcF=pj?9yy{p|f#T@;)vTv3A z0k@_3B`q*YDB+cmN-skqtiT9GwOA-)JwT!fAOW;w!m@li07*8eB>9qSLZg$;NtI<2 zzD^cXLf#6qH|!MXAgHkLY52rgk(6u!r6N%V?GoStC4@7@KUYQ>;fhV^7d@&7^vVY1 zkcq-Fb|OH;s0oV(XwLZv`2*9DP;KiCyS?c@en}`R*ELWt?a+eAM@0kzNzNmEtvB;H~u7Z=q)e{0PtgWObLm>)yMxlkFqv zu9M$gQ)F`TQS-#D0|O|n4zdCtg-JAubj%Q}gZ^br&pl(a9E5%Vb}=D>2xkWcq&V3U zg~m^MO>3*yi2K{^c7L<(4b`f8|I(t?H#^(xJ9@NPIr3Sb6X90C^kBG_joHl#?(Wy) zO*bnfWc9dQQC|O`-7b`@uvCKo?S>&XNj%%p1KYurjcGuALLkFL+Ru#ImesB+6GaQu zzA=#-VR}Z!{gBJk1YjeSiDlMjAO)ZZhl<0UiJMg`MFj7}$SuNwg6S@m!gDiIOKOPh z0HhzG%)_3CFvO)fj`ligYd{Hr2m-&Mj=H{KLK?%mFQ$d#(2RoEjzTP*C#fB@4BNwQ zO|z{P>MKpVj;eiaSu?AP%AS8YvA;eYLJ{-9U&8*w*Fi>|5X}l?36NjqegH(RcK6Su zi+A6f%x>SQT_}w>v*n_3s@xa;X=xyWFUILckMPIBY^$jUFB*}g9y7Zf6QUkFSrK_qC%<@~=s*;ah+7 z5i8d}uAQD8(2nm$3Z7bhEO#JQ6BA-+h$egE;gw&1k25sb&P6#3nG=ya!R)*imxu>o z$#m6;0V6tWoy_Km*g`?z9dOdw5RyeR3~*xQ_(WVf3vLd0Eey2+PNa6s=AG%5m|iIA zEiE=l(i6e?*fFFC133g{3nl-n)rw4yu^wWLfj=mOOqnae7^pXiJv<%%3@lb7 zD@EWIMJ2R!jKnGGi3kb+fJC#2dcmCnNiiE_@0~P~0AL&$Y%>XAcuUZ1u*;9ZU?34{ zRm?VMQ~@vWtJ%h-Rcj^Isi)Cmq1&EMp^soMP>hNWbD;uW2e3ia#)= z70OYRFZ+113ftwf)+qPe&2pdFC>70Wv7jyFD(ON#r?(K)uIb8NF)e!r)3Ya$7N=T( zN1mbgPB5gyjo=pubW_P**mpw_$R&-MPLkB^II26Ej>+WEqj_qkGElYmSJGyG{ZIed z4zX*|dG>4Q_+mfg;eGo|ktc~74ZV=t1&d};Yqx{6zuAT`HJfZ-T7o?@ooU{5(1ycI6Dnzz)L0lQ~!azXBA}sw7 z;aEV3#B10(!s3B~!rK=?>8_kkW*HVc(}tD@oQN=NX3CWUf=POEB^~2s$de?gI93(P zhfuD18%l==v!)n4RGGEl6$IH=cyW>^T;N)mCUpeW8?b_Wr0IRE75ToM_;wKc!vNYh zLx5G@=V5hNpP-_vjuzL-iwrcho;T>8tcZXU@d7Q{R2fL+&GLWEeTZ9agpC&XzP3O% z_gez2TAemBov!jWHc7= z>*T@3N`n$iEUbd+xO2jf3gS2rNKo01Asi8lJt!f?NMH#~Ei#+sn{Y}n$_8}Mpq`*Z z4eP11p|LI2`JldtFbrvs5cC>iAc1H($;FuGGYJkJTQZ0`noykru%O^lM3|=mIxz!5 zuEAFU7Vh8y=_xv%=?eKXmK~oFz7{8Qh-Er4`BKv?1|2a&%CtxhWFAdJkx4@mM~F(Y znM{sMkVz6?nFAv`T62e~H@78bbFh?a4fEu=Z@l1+hI3J+vE={S|MH+{gIp7ZgF0k^ zo6EaI(@5g|`l;;)^_jxH?ERkWmXWC098L>``Q+%);biim@0|*==F*oXMGox096f{> zXw@ymK4Lpf*Gdoz1r?%$mWLFGPg&H6MTSJI#3EZl1F`{7G86V<&K`pRm?@wPY4#Cl z=SHG6EE;&(3@q4GKnedsT@)&qY!JdUVo}=_N{Fga20#LJMDMW0C=Yt=YS<(Sq9q>h zHEh-ZNc^_fjGaQ$@^!ze+-TqAA+cA&Dc3)6QSD*gTV}Dhr_y~bw^?f}j~Cw)V12Xd zc`a4iD6`Q5Z*~jNzc<203%tG-V3GhI$T`;lZm^E}p+ht8!ZaJ^=Vo7CU>(C>ryWD`whTS6wE*A=Lf@i8W|C+0DQW?@3vQ`QpjU=BLR0uUn; zaV5=EhCLbLwSSl1Nla-;5a=b+98h#N;W23w)?>xdOXlo_WqT19b~*g_xJU&J8F5rL z!>D4o;V!2UPVCqrphG4(WH&BvCk|woisKTrQ`B9yWwr}!u$`MumD z(zibQJKvhjkP&BmpM$LQPpQLpq;lBXF>OSlZRC#toKQJ5RluJ`o(IOOa0(#7_g9Ju zLU94YF`8Pmu4F5AVzIuQfe%_3>p-x~Xdz)~!k#28fLAubBw~R`v}wc=kN`iikjg&b z1?#B^Y#RVc21cNSh^z=!syPYQ^FkRxn+6b)Y0@NdJEz&LK=VBaF?+=mv!nLUG zYslzo@-Wh;*~G3M__;rLU#P1=q(hSyA}V;U#CcMR+iIr8Y|@7xB(YFD$Y(-B%9ecy zD6FpmIS@^Lw*#Cq&@F>Vt%oCKHNV!B`=8gb^Sx99VfBG+x1&?i5KYXj-m;LD&nPTybnVK>B$iBZ1Vr6 z)baX1^~M!9THwdG1-b+GkM9F+JlXYafyqgQ@F&yYrw$r(udMtVuD+E{1YXIUo5{E? zeF}*X?6a_>hNK_m%@s_bV*DH77MMhwjMOa_b0tQRnzP(;)^(BePNBao0Vlhja zC|ILG1aL@5GZ|t?ST2q{jNpj`X&ZS&&H>2)SHKJ$!!%#URO+A_l3l6^@TZG(x_Q~e z%#D2V4OQI^vuTe=uB{1H>sY=Gr8$J%Sdfd%hNByn z-^AFSVc-wJ3@|T?)-#hfYTQO_Ap`;aBFR_~S{mZNTf;&Ns7JhNgIbS62_ z3}<)Ja?&LM0G3%bmTZ8RU>FD);5llSDO~~Bgw6q(L#*@jz>qH}3lS&VhfKq9yhBM% z6D%i73^og(MJMa>XIUo~(zBqO*%1MdbTNsTWV#r-J9puqNHPzzW$YW#^%bXz5_%R_ zd8_<)^2nZ;m^6onm431xJAU2k^se2zS9X5)-qk85aIRbE!Kw8ZY&2%Cc#mRn(`vmt zc4pU~cj;cgJk!-e??Jm|#!E9<`NLN`r+aL8c;D5pot2Xp&D_`Ic&d*WH`mQmwP`uvcvXMivpZ*%)B1+DbpTgX>rtFWVI5LllTY&X0p|69_}YN5+)otiUw`Q%3Lj5*c5Wy7PIJA47)GMJ$}jlYwz~1 z{aPh53$}8QLF2CRj*~*!6$*u@*Ion_lO1gqIVho9Q=)FmcCz2nNL&m)FF z2$NK<(Mbyv6^jAFE96>`2T8;1L!1eNNoc=ePJrXwD!Y+|gXZk^*Z{yshvc&_^h^A8 zq&WUaJN~jzj^kVmZ+G%3hc^(ssES_?Y&2zQq^EeK5RddC*>2#w0Gm!6$JTaektXIC z)*~rn!N+7jfRC7^!_@R~Jz@}>2!0V`T8VU!S|*;{TN3+F2Hg}@uheefgxhMTb*GrR zE3Gu=G}EeEP7Cw>>0GX!7K=-1|H-lR7}Oz|Q1v2gObq1)EI-CnNv zCOf>!oda5B2kOU;th(-!HxY_y0Q;`FZMs za#`j#3Na-#t+2hwTnnd92#iX^rbLuVqU=wyvo<2D6LWAdDcl~GR4$;xHVE_t1O0hL*eDKF;& zL**V>Q8{1Gq|`Ob$)159&Xo&tlzYmBkx9)OP=6dWanv!?6fRDdkfjQ z!g}t4=Iq*;CpvBe@TI(*ByBH2+Ou$rsM} z>iNC2z;^B{4IE3ZEVN+IOOrv(r zj{yCmv(OE3w{9prU}H?~%|d|{jd1&mBc}Mh!+cT!U zaMsrc+e_L&ZXjLCZ0nWfW1)awta*I~KWVUY{C zlKq#GjgLR_lz zi0CMZ!TUN!09Wwagt8Hz6Q6UDc!X^VM8WZGvl?paO6WX;tZ0eUSpRgoXB@Nr`=;0z zQVidFHpt4+H#bbGNqy>I=Y7hZrg^91nQX~EwTS$Oxp$NP(T_eTK7rm?fM{{>kkaOd zaUcW`$~@F#dAM=<_{DbR%4abC=vLclX-^$nNE7M)qbdcrW$nX=)`{O(4c456mFk6>tl z;d>r!FvDa_H4$jSPdrXp4Sb)XlFsmnQw$ODq64L(lAcf&!;>1FObk$BWSZy6_9ZQO z4DqP1chXXBwT!5`_s;EzeaToaqqlTQKp8S;nHHM(I+J>3+G(BqwdJHd9ZHnfvY3;y zAbFjH%o=Ob?DqD^?-uUlWxKtj)Vh0gZK{`*&Ck7^b@3%K^LsUz(dW=p~u2RWiF!cJQ@IT;W3UNMy^A-cWZ*sc!u)jlN5%?lmtx zi#y@$#=?nCIEU879oN#LmtSKe=SQdoWVxX_RF%U@?@errjR7s2c;_ETriSO!PaQfa z8Z+OeSH1RQixa=8>AP~xz{`2caRYG-iI#GU$)kk}B)mC0VW=FRS)>&i)(UQjS6E^A zV0`TAX+CQThmjeVN>(1{!q^%yT`jTMEXTk*Hp?FD;epw4`;BiU7ja|q@dp`q{6dN{ zHA@s3ztIZns0#sYWR)eg?kv+8ido2X_GEF@Inv1^-FyLOZC=CLSzh!4!pfZpUY)hJPrDqX6r|frD30)^+%cBAxd={Dg~q zviIRXofHFf*A~)r;HJlC-zmoDP}~ZPFbH`UzJ~8{Q-!|eqXngTf!jiJ%3ttzJ~!`I zKKh_P$+KVE4X#p(#^pNNR1X}L07oZt+P0#tbv)YxU~xFv0YvlZAt*)aq3)Z?Mz1&C zXRlJLALt?2Ir`KTphlGq-w(|J-Nq+b49GD7J?PB=#xQG?#iN}F#)4UZ8h}amNehsZ>vUr7lYuue z&5&w%UrQ;QQN_A;h_W>GC!gbjxcGSFbCZolhorMlw3{dDk(n}IwlCmJzT6I26Y2j> zx&zL#mwws%xqRTdJZJT!%MSHG0|&DIZUG*;lj{CBq7VN-idO{ z?4Tq%$xeUmW%6X5By^fATmte}kFUFAr>Oi&+iWT`r7FYVDs8-^Fw`vF4Os5 z*0O$jHC{`*INITLzoALz6aQ?|op0v*T8ZArI97>tRc6Inf>0^O)rwe%DL@d-p{$%2 z^&Igu5GH!;BMIOWL(j5ZPZ5}}b2leJ0vu42x&9WU@IIMIWE3#P*vuwtr zH&k59M8R8+v}~W)z%2sHWZW1D8pl%sv~V#W42cxilk6L5guFqBA15d$qUeoy12$|l zEfnXMR6Rl&Tx1)dydal=$3W6TW(Sf$QI#=P#3Q5)$p?g>fG(4DRvMx&hG67u8M`}; zDdREo0&X=!Y6tFu4$fj9I3kh_j0qIP=QksA<_kP~Ia#d`qu>}okC=tOk$8~_8CGft zbinCV%p5?iQ3U4+AR>5?5w=1jYzDU1Kxs^ldC+ftVn5N)G&6Lc^GIVEhh{?WUBDk2 zVHm4P3_BysasqkT6Y$Vbv3+KtHm|77sDn=-)3eU6EG)e1mi^Vuqwm3GIk&~_p@r6# zWyJw6B$S5d;=J<+RV^?mRi?T$GC-Ko4a`RWz*Q(N0Z;+wx;}(&kGwjKfI9)LhJ*-C z6*0`1=uKW%)9_o(LBHPk(+|Jn+QC=WmRI?iOz~B9m=`p0g+hlf<~?>Xd8O0@nbXsk z2#R(EC#xP>R6U`tEU{jIb5$^t@}iY=xO;;h`?a$R8*hzP(d6Vq-8rs1!FA915sILa z9fV5uPW|kNuNRAv?&5J|#Z7Sb%D3IZBZ+Pq5g!Te%b|c_w>v`F4{078(&Adp#NAqC z@uBjmBOH`brtm5&6f@F5o-9A3ltIA~VIx`!iWpR}jN&C}0Z`pMV!r51+md+`YFY=F z$$EirSOA~gBLi`K=K{I}fC;IK?-#0=*p$k^m&C#?ks%?=BQ2mzw%)PczJO4SUV+s* zTQ;PSC1DLr=uY*WqunxDw5CjNvL}CSuIzRHJD1JMburN@;F3d~If5ul%ddoP$zCGM z>2|!%(e6(7Y&oSHIIwzbOGtn^04_q_#Jxxxn82fO7fkkVImVX4RXF{I5W9!p-Yf1OFxU)AP;e50z0S%SF@orVReALfiG zagmF}79ApvTnU#8h76b%&w<7^Gc*GbU_+SZ^nRX{ku6JRD1|J53& zM(_=Sp;@3vCNnBmz}}32C4f^5EJ^UN5C(>hzi-WF-pB1Are5&L$@q&%DZ{yLc(J9| z@s8ODth{RDWgHX-27|)HP%H-Tu#=qqf_J!GI)GAqo`KzAu(VP1#^_!O<3u^)qmyT~ z(Ek<&vl+z^o*s0ZjVIg)rgMM?f|l=w?a=X?LC&uSj`BQLTZ!zXirdRM&(`OBU}IwB z!6i6q#4);o_vsZcblulW0a>-tfSq;4Iy+F{1U z33d;&tq;ORJ9Lm*KTQ5Z#NGb0zSK4Cb;sWB$j&M9{jWV!Si-&iwOTxT>SLY!u4c|f zEgV>Yuoo|?lCK9|y=$!kuwI75YqVb0s*3l7f~80Ygmu0b+5d41LMfYmWXuRkGlpFn zV`;T)Bv8wm7N38E{;zo~8%zkB>}SFs5K39#=n=~Z=2gjK*+QBn1R#mQCu41Pz?r1G zU=6@TDY+n&D!vcOaZ(r5aSVGAbVZg`opK$(CC3GX3ENQsmw>BPfF=3dj$<5`Gy@5j z7pmAQFze(oFmhbTU=pv{q#XNO{M9t~5UM!)Tw!$sSY(7AStN3luOd6T zdYqC}UItq!*vamYz`F%|*P@`DDZ5gx7uJ-@veq18UycHNS*N*p3iOs(XB}8yqIaE* z_)Tho4tQa_)Yb4l=^TWP%*s2f{mFx#KS#`%Ungvs97Wobbph^^xJpf$DM~SQgF@yoJgj>g$iuli|Lm0u^u-1YS z(HJRy^SrvDaX$K#+S^)D<#ubQ)9^+I!nRwC+l~nguzlMu2CiEHkCa)<<)9kaG9iK( zm>7lnA>Tb@8|=71DhSv@;6mg@n%DMqIA2g8eBFyYqa814e%#ap?Cpbvv@pUGCZ=v7 z&!A^Q4B&z4BZ6|7ong!vi@X`6j!fi*(I}gxdV&lr?;vvUdKJxvJ5G{!3{`-wgzt06Gr~e+#VPDEF)_#_4ccO;gb@V-iy$gR2z+2D zFY;NnHk=@8njUVzqquE30c5QpFr8M@%`eq+#TDPkEg~cW=)-H0iF;917&imGKM!XC z>%w^~4x{R#={9#Rz6)PSn45&)Jl2mfJw9mHF zKFdl=G#H>MwOAV)U@=SZSt3}>GH`Wn1!2yK!@Q$~c{d6RxhTjv5r9YmhRo2htt=)D zP=Z|o;O0?5uy?b5uVEF(@=?2s{@9T8X&30v1JC)DzA140@D(+ zLJfxub;x2he81O==e)+D-+KESmo1_XNtmQ_9_D-Q30eyqO^7;;RaP2uMRk!YTEj+T zcqd#$G_v=@7rJx9#cE2bM=f4k-HxjDTAHy^Yk}C>vez?xHAibj_lUwoaR7@v!KYC zc1dqTN*2&3%6LYY(=(+!AfbhEvcPDwzGL7;wg3e^S=z`DD0bKRM8QY^LDULG26$b* z5a`8uv2f_+!_O$^4Br%Ey~C_0^f8X{`H^OH5W{6UMq-o1TaL`9SKs!J?tA&6t2twB zsvhhLF^11*8s7qn<}aeVUQxHP(2_|)*t z@49gBWOD4+Sg?<^EV}{00R2@%_(uQ`qtS?rh2_wgtG1lQxmj!FZ(cO;_hYXhU%<1J zmy{X>jBTghLn0*Kq3d)sJb2Vp`gZR=iGD8dCz;^ z^De*ld4A9Huzd%gZV5HMXwa1V06zRx(HOvJJcyD5a4;JqZ-O`$6W29biMCcVUKRA> zH8HcMCT!Kk45ucjdr^XRhBO=S1onxd$uk^VoE5u<4YafE973}hU_vQSwsLye%mqhh z#uLReXM)O^f}xC;B8E~7RTI>)1F2h^qLorz(R@`?SdF6&K+3{<7SLOR9Hk1hl>jzI zX=@RfK~b7ilo2JwsA0i?Dln|%2}(&M>hhFs%!~yCC&s~yQ`j9%DmBV9`RCOxWg*l9 zbxaJXgQzY~B$Srv)Qv6Krp|b|C281AVa=&U(N)D#HOat4h#S3nn7AOK1f zNDVCN+ZQ?O64+8Yv+qYovPjFH%TLRJy$fA^DG=Db=)&QOw2ot9W-mW226z>VNIAYp z%_(0OjZO0Pmpff%1Nh~xgdgMS7+sJ;&x?#JqGDOAh>7#PefP2)zW4z2fu4^?LYCUd zq78Ynwwh_TjoGiA$)t_JOMpu)6Am#U{YjAke_kFSF$u}zBt!E_WNxPz7f&OU9J18% z5Nq=Bzxv#tGWlOtLLa_c>r(dX>E7Gi-S2xOd_&W7Zx$X3$oC{_lJQbmnQlve1z>UU z=cwFGJPs?FW7;~2 zfoSN>ecK~|+to~riG)&)SDU2}6Ck!Ka9aTBtmK)vAUg`shp-DZMwO@vfO;lHF`9(K zg1Ul^%oN59CHG^ofc6bo#uW&#I7Si^Pb8#rDJuYciy-S|mB1PF3*HRk5riCbo{VvC z!Z-gCiDQM7hm*v>P;~1e(#7|%#J8d@=ZYfY83*ALh(v?b8!8o*C_aUiX+YW`Os_=E zrwG^O5>f*XF!b#dGJq=Ox@@)5RIYwwq*T}LnJp>Bu0?`WjbbPn^sHpUu~Q+}juVGm z4>@*`>EA?Iq@l|zN6LlqXkm6ZIGdXeW^x51e<~YQW=AOl%Q9saWL6@Ex@o;}ZCT&4 zwroaf4CHE2&`O*~sz&wGjHs&xB2p88pa!u|3h+|}QV7XI0|0n#Bqyo`IIzcctu!7o za;L-E!9gXcj3}#>QS~i~ND7xvZ#t4xqR%(>Xobdluh0O(Y6yCE1Ldzn)|f)>RtpHK z;T=VIqo^GLPw>szigvKT%?n6~OgHSS$Z|lzSvwM#wX32ft14P9$0Jrb8S~1Kh+YbZ z!=+FlViL*|xF}J=tAU}Iw~5LY6`bqV>IUjJGL;4m7@|v(FgZnZSY&^O4~q%v&hxh! zPGDgnAN7pu*cswrOi?4V311gd5IRJv$bwvBJcL@@3N_)volKKDeTQf<$~=xB)JPeNIzh3B&M`aCzZ9cEmy@# ztydXlV~yt4Bn&5&u&w5VZP&*fh}*8^#B3`RcS@mzTZEmHp9$Kfe8?^n|7oTaE>4d^ zhZ@&sa~XYd0_mAA78|Q$@@8LvG*S=6?e9+qLwiT7!?T5I$12r_-117>a#jExSMd7^ zp0|beoB|ON1so9Kd!6udNP#ARkb|hi2BAd{1(eyLSnR+_WgUIt4RZ$f7ktk5JRfV1 z9IAbtnXgGH<$A+$R-mwK26!}rFY5q5^#HIs0c5nJqz{A;c5zJNaY+3YDM_C4VE?a9 zySh5%CBs=e5ze{saL!7E3#F=J+<`{#S&Kf7RkaWf_$bq*BGVKiTLw&()!Q&c~}+rb^dg5?>F zjdWS##iFdP>|UvXm%Rr3miw}o;^i%X4`POgoAnSrTPi>x-ItcZ=z{4_?)M8daLXwCXeLh^#GE5 zf*ER-3j@00;X9)dHP(POv1S-OoLhAb2ukg{Xr0fOaLI;;MYYp`gr^==PfaMdE*P?*zdB((WEtTIJe>$)8bVWP zVICr6<5pIx5~^Mk zvn4ZZsiu=HS@GjeDXdKKj{C%E&L!XFk_VXo^7PYp1=>?eG@mWSVc&#_xgFMnYC_fR z1XE@Nl@OX_-|_- zChE4->uwD?!Bon0nwu=EsTxLh95q<2TTa!uogJ`P|%j?>y&2&3@u4h-|4AR1`4fC$zBcLr410f4$#p7 zhL-k{q~*paJC1{VwbmK7lPJ~EA$o&kbdrZYsc5;=G4Hw2@D0Ubbvs1tOQiRF@Y}Jq z`J;`&vfD^|Yaobc6T+n?`d%aI)h6!WNEfX`?#KN-RAd^F&FQ5`XE+ZB*Z?8GkjD%0x8;(E!B7D!)P=_u@S_Mn4s{vFY0r~1 zTU>MVanvk;h{)-B-a+L%J3E{EY1CzDpF7kCcPQaIY4o{6^Ztnz`y}e_?sn6aG3Saz z=n}2*%Fi!V4bYqZU8My{3=~^UnNZv`>LUgp#*g#UpS|^?3AjlO0WGwODX$%#`Z|;r zm@23n>C3Al!B_zI;|eBbO4%yPRy?vDWW{!n3G$HZI0uM`d<38jmq7)>_${Ud4*Q@4 zN6Zy)w~xnr^kp(bVbxxS#a0Zc4r&Mo&Jfm6no~ezkd3JJw5Au&M54+}9{PSD?iCa5 zU&+AWTT<6^HFEynQy=e%nAa%55lqBk+Fm*2zA|Q<_fDmkZ zc0Oo8K2r0OH7yS?8KSniKXzT}M&qxsm3Tl07^SgqJ?aEh|?GF>5x}(LNG}<8fUWFeZ;%jJa0E8y06&HX=J5?2@cD~dK&5?(6PX%zO z!yFsp)t^9fcoGLfC&Ef$sJ^*xQoZ|P60WnA@TouhXu_Tjr!uBfRW|iHm`u&wznM4M zLjHguMZi}Rl2po~R1>omR=xr1mlovU?T7wfDi@JC3h-;FT{wyBd(e`Npy z!4m4Hb&U7(czK?eM(C7wGsEv%4LI1r@VN$+2~rWB+GR3P)l-;%k29u^ApJOD7v0H* zlUGfu+b=HV@=(UJK;QkT=kQlmI$8Q`7Uanm&WsO9p*Zj{O2l{Nl5^S}u4VzVE8?iXJa2 zag4`n8yKv!uF~A?C@H2Xy#MkXyyix0IGmuuMgK=+4ZE{EJ-ezRf?Vr7f6lUo&jQ97%m1r!EMR+_AvJ+5&s(Qr*`lq46@ftf^C$-wMs%l(2*65YgdazubTd?XdM5=P8)r37|1%10&a2{TK&_3oc z&qs0>7{UMC5qmarxU@fTY_syv@#RYZa{ik)H|4W-JF=8DfX>yR!8L%M)wI!7w9ZGCZ4)jBfhK7cb2FS*8%5dGC9}XPmBN z-o)Ls7pwHz(TsT2)G&t?V$6-3_|Z_8A*u2-_(a|mb)G>|l*N5R7Qs>$TgKVG?Onnd`w0_7>fOCOM$ZMV@AZ%S z+#&YT;3A>)p2HoIgH->ObfpI7YTy?g*vv~{Zi5SRSiO|LmvqnWV8Iz}psOQ!QLbVj z?4&zvVlY}uMXZd468?$^0--N~DOrUQgUAu8>Uqw6k$LcW&D0MYwUZ<1i+Fb34S;#r z2QXr-Lqam7bR!))M*`yi_6q#70Bpv7YctZ9*W#4zMckYYsd;Uut-@{a^5XqC9Ps{uMGs@!;(78xiAQ{ z4Cc%w zzckgZg{s68-$no*#|`<81@Sse1VxcLjF%l_s1%H!>1zE15{L^YZ;xX9ugCve?Y@a< z&HhZFez2ge%-G5b%#&K1Rj;v5acdyE1RGsTYX(y_*oAzvGpjW^86{-QW&l}^w+1ZJ^fiFy)4KRwd&OIT(bq|26xcwAak!} zf^KKdYh%`1$-5MZPYlabge<}wSKzn7b-|ntRpoIe_2ak_7(^a72oLPUtkOLGK;PSP z>AoLNg5jkV?Td#$RCUUzPL!;0HFk8(Jj)7TMJpgeEF4#&l3f8vfP#nNl6T=<0E|)`YXaSXm9$mLZ8`bR5M6LfRQ4QW{~3p3#EJ1d^2kTmaYv)2615 z;lXZtA`%>%S=W$x@y1wD*?wt{v>x~Vbf7gdr3NA;J6dHT?J;dVVmW;9e`3071StR!xIlO2xf$br>tCh+{^BE_Oezmoq$Tit^2(?mDUJ&S@pC zoiQ7n;o%MS(>MR|CoW-iweZN)LW5U7=l}pf07*naR9c&vIb4M-r4a>P3j!ulyTFz5 zLgIUZ{(^rZHA4-I!ta@|bFtxn_?P+_`oTq27hZo?aOXe#Zv4OrqjlU=R~K}pjoj9O z8do4Ps$x11!}fJju9I8gmaVmR)^4^lZk;t9sdgq}5pOoGdqupUq2*G82$}*JM+qW1 z2%K`X;DFaBCY}N^ z^U)gsl1=D70bpt?b-s#nN&UmqQR;O~PX+)#Y|%Oz2yAGr6i7gjh2+KLLsKjP!!tPF z9zi8Gs+F8E)p94CNB{{CYT;xkV^v3I3r#5{*H~j@Q&oB{p44$E6?b$Mr=3a-#OM@K z>pG;0aLzsaVX+k}IM0hgY&P(ng(OGH{5!fTARG-NG zWU8{W?#>$&15U=i0^K!B&#kY`3&TgL$m&Ws+7Oa-H*F(amo!gW1{Mk#|0^|UUe zMm($|&W>63DEh3)s~bWickjCUf;^8(jsE%W-BnIiRgGY zZN|Zw3al8!IoQ2pG3BZZNp2bUf%xZxM-p^OuuzJxHD;U}n{)P6kyCbUVhHx=cigSY zUKwqX&(954Y(+97@3Znw7xo$4rJrcZ6R=vQrvgf;h|j(teuHh6y1A&L6_bi?My?8Y z;dN>$o(yz#JW#_6J4HM(xyCod`4lf3VHQ#yIv2)CZmg6%YjF5c_voT@ix;nXm7OZCA< zcg(d;snF^c3a!?R-D*vEHTIcU%$~%ziO?BTo0*Xcbs9Dr>0}7Dl3$R$3xKUBeBKpUHH=!po_f zXD}INY!OxT04?VP`+yz=z@?7lCId4Y{`=R&08T)$zoP9YPU0RS;;7ZM`xKn52Ba#~ zKYSlR5Eb9V3uCLUpoIzmszPNR2nSGq;YLzGkc@jSt|)Ct$u{oODDD@#K>nQOQ(1Nk9pn!;h_|MyveQ7WZzb(2ZESO^A+S^ObS2LgqePyjzL`eZ2@{NXs-%TYKx(w0OrBC(Rr=No*`iz%-f zs#X%?wcGV_szJ4Cle&YiA{VPM1VXqS1|XIps>*0Ov~{GOLFL8m)4o-YYj^vlCCiXAZk&TzEa?57J^%P_^TryW>hB zu&?7se+$@Onmn&Z-@7ZNZM}OJ+Q{?E;d%Fj3THAkS;t<5sa7+kEq3j^m#cOa3avWF>Bx zt8?w?HoG`+l|5ZrVNR$`=13^&P5_F`6-584#??(VR4A%hpT-CC2sZTth~oMw;Q>Vt z9i3@4PHfxt^^1b+-Aa#s^M|LZ0}(@{DJ!t&ZsR$nKfuS6eRXARG!QfbO!v&J_)t`4 zio^v0^iT(bNT&ulew6c%0&L;bTs6lnl5Z7$kx+}${?F`)1Zi>lX;gZP^Z6y&=Y7Ad zNxf_9{lryE%rd6lAS6tQJ>27!aJE;*ip#Vt=#`ARsL5DdGek4B&p*60AMtieVmOw6G&Cj# z{xBU9QjZ8pjQ?Jc1#lR_Kmrw2n5a~CFldzx!hqt12QMZ}o5A9AI8+!3hD(zHqnrx_ zGoz8{v4L>#`(u%SG8Jf2PI40=o-e6)e@dRZkI*-Ve)Pkp;T=j^BwGuK-?ja{Jx1q8 zQ#7XKE!`$2-imckvISG=R_a=uK*@y4WQXcbQ^uIs2iZFSd+2m?^JgH=UVgfN>z`L0 zt4pn%2?y6RLtTS}XFY7^HULix0m+Etg_sO*xWaRXI-LAuhnbmeH%HW3dps3$CK`05 z+$ww*d_h(r;gKMwS*TV+0G>m*Z8(T3<`7Xt_nS5PsZC$}=5mXlC57eLw6XN->kt>! zD?xWP7EJ48p~XoZ)YW?C?3FPfkHp%L$yoGUvqeiO5$}p~#%OcL+0u{Hqu^@D&FV>_ ztDZ8?)XcViqW2~`=AGKkFBbP3&AY>hEhbYGmDW~v%7nZnf_^{MU@9wvZW2+EVieUa0qy@_El>l z**!OHU36eC?0S3_;hI7@>*SHZtowUB9PXV3OmlU1wqh1#@r$r~ekHtgrIvdyR2&gv z^)=RO*05Oa#8~%LOfg$H0oA9s0^Kqp_yolG(+(>k2N$WRlRZ`uigJLlVlNQ?NBDz% zesU;uL_80k`xuM)FuzP(m~)@=-0xH{VCF8Psv)FxJorfriq6*gshd6!DTT6|>F76c zT%=zzpv=(kpJ(`bifEJ1wDo=VJhNOegJ0_vox8uDzUWH#1G{%e!u65WgbL}%tD#lX zL<}Exg3Qu^T)(4@yu~d|Zm*sg{?)Z#Dz!KF>Ij=_a%YtGY~hWq#louAe6g<0EXAYc z0)F+2v1q9fQi{0{#vE8sl~fB)eY54VgMVEco|Kz4i33zB+iuCk_||f=Wz)%{F5n25 z2DOZBxkc0Rih0W`6w71-8ZKkgJ8Mc5ZQ;!jQ@}7_b1DY0hm{BcAj1Lt^XpnLs3PIV z))v?LN;IN{65&uZ64k3B1dEIyp67%J_9)><%t=OKIMoTe0leto6vj;9S%sC zA_i0`5Xg@PLer;%!TdxdwEsXu<1>e`+`=LWc)cW{g8IUN6>n&qYG0)l+lVDDmMjH435I?Wk&t;Fdsy!poSR6jn*S-g_#-eas!9Ig%;T75F$)DsyzywS6&*WsjZ zomq^oaZR}4 z?zwHOa<1py6&M-GwS-l*6I3U>H&i_;24o6xGLJ+bYg!O@^^w3IcjW+Jj>q z0(L30C0oG55Kl>6dnW+dl)*d7`yRyQ@a}vpd0V*#1O60Vl!zJPHgNw&Cg>I>YY5Mfo&l&#;x!B@bxiuQ4J0B} z%4~V|Up~@%&u`v?^zg-NBBI`1ND?E0MaRqKB7}4gOGUmtwqPoXc ztk2XOW2}H6an^|BXr6E6xjyd*8g~JPAJVdN(Fq#`FwuBctIt%3~<6DHbifLlV7)$Oa1r3AF1!3 zX<8`U&otLdXD0Xc-q(|kC*9P$c&u0PxYzRXJ0QkY2{Az=F_NTV2}1!BYxz1z4Zw@> zo~ENeWjO8&|LuW4IVC?VPQF?7B2EmTMk?-SUU`e}`AXaO0E)`7ly>f_`ta?4&<^?a zHaZzn8D$kO0?G=cX-|0^K>0-bcmIJ^m$lPQ+nS<`iAnIxxV(7;QLkw zUBRpHXpkULr9ij{b5$uiYC|Ft-I$8j1n{a{Ci+k+6p7VFqYWW75RZm~u^7aM>S{e6 zO)B9;!VM;>&6Y^QQo_+9nt?(j6wXft!ns^HHhn6V9NHHOA04Vq92fxboMv2II*~qb zu;sncQ|n`f*MX#|7MW3)Nw3I^az@l1mT4Yc`^4){qG?=sUS62GWA|>YcgNB|tHN

42iWmOkA@SC=5JRgA5~ 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 literal 0 HcmV?d00001 diff --git a/mqtt-sn-client/README.md b/mqtt-sn-client/README.md new file mode 100644 index 00000000..6105e0cf --- /dev/null +++ b/mqtt-sn-client/README.md @@ -0,0 +1,72 @@ +# MQTT-SN Java Client +Full, dependency free java implementation of the MQTT-SN protocol specification for a client. +Uses the mqtt-sn-codecs for wire transport and comes equip with UDP network transport by default. +NOTE: As with all the modules in this project, the persistence, transport and wire traffic layer is entirely pluggable. + +## Quick start +Configure your details using the code below and run Example. + +```java +public class Example { + public static void main(String[] args) throws Exception { + + //-- use the client transport options, which will use random unallocated local ports + MqttsnUdpOptions udpOptions = new MqttsnClientUdpOptions(); + + //-- runtimes options can be used to tune the behaviour of the client + MqttsnOptions options = new MqttsnOptions(). + //-- specify the address of any static gateway nominating a context id for it + withNetworkAddressEntry("gatewayId", NetworkAddress.localhost(MqttsnUdpOptions.DEFAULT_LOCAL_PORT)). + //-- configure your clientId + withContextId("clientId1"). + //-- specify and predefined topic Ids that the gateway will know about + withPredefinedTopic("my/predefined/example/topic/1", 1); + + //-- using a default configuration for the controllers will just work out of the box, alternatively + //-- you can supply your own implementations to change underlying storage or business logic as is required + AbstractMqttsnRuntimeRegistry registry = MqttsnClientRuntimeRegistry.defaultConfiguration(options). + withTransport(new MqttsnUdpTransport(udpOptions)). + //-- select the codec you wish to use, support for SN 1.2 is standard or you can nominate your own + withCodec(MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2); + + AtomicInteger receiveCounter = new AtomicInteger(); + CountDownLatch latch = new CountDownLatch(1); + + //-- the client is Closeable and so use a try with resource + try (MqttsnClient client = new MqttsnClient()) { + + //-- the client needs to be started using the configuration you constructed above + client.start(registry); + + //-- register any publish receive listeners you require + client.registerReceivedListener((IMqttsnContext context, String topic, int qos, byte[] data) -> { + receiveCounter.incrementAndGet(); + System.err.println(String.format("received message [%s] [%s]", + receiveCounter.get(), new String(data, MqttsnConstants.CHARSET))); + latch.countDown(); + }); + + //-- register any publish sent listeners you require + client.registerSentListener((IMqttsnContext context, UUID messageId, String topic, int qos, byte[] data) -> { + System.err.println(String.format("sent message [%s]", + new String(data, MqttsnConstants.CHARSET))); + }); + + //-- issue a connect command - the method will block until completion + client.connect(360, true); + + //-- issue a subscribe command - the method will block until completion + client.subscribe("my/example/topic/1", 2); + + //-- issue a publish command - the method will queue the message for sending and return immediately + client.publish("my/example/topic/1", 1, "hello world".getBytes()); + + //-- wait for the sent message to be looped back before closing + latch.await(30, TimeUnit.SECONDS); + + //-- issue a disconnect command - the method will block until completion + client.disconnect(); + } + } +} +``` diff --git a/mqtt-sn-client/dependency-reduced-pom.xml b/mqtt-sn-client/dependency-reduced-pom.xml new file mode 100644 index 00000000..c3917f92 --- /dev/null +++ b/mqtt-sn-client/dependency-reduced-pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + org.slj + mqtt-sn-client + 1.0.0 + + + + maven-compiler-plugin + + + maven-shade-plugin + 2.3 + + + package + + shade + + + + + org.slj.mqtt.sn.client.impl.cli.ClientInteractiveMain + + + true + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + + junit + junit + 4.13 + test + + + hamcrest-core + org.hamcrest + + + + + + 3.8.1 + 1.8 + UTF-8 + 1.8 + 4.13 + + + diff --git a/mqtt-sn-client/pom.xml b/mqtt-sn-client/pom.xml new file mode 100644 index 00000000..f0af6801 --- /dev/null +++ b/mqtt-sn-client/pom.xml @@ -0,0 +1,103 @@ + + + + + 4.0.0 + + org.slj + mqtt-sn-client + 1.0.0 + + + UTF-8 + 4.13 + 3.8.1 + 1.8 + 1.8 + + + + + org.slj + mqtt-sn-codec + 1.0.0 + + + org.slj + mqtt-sn-core + 1.0.0 + + + junit + junit + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + + + package + + shade + + + + + org.slj.mqtt.sn.client.impl.cli.ClientInteractiveMain + + + true + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + \ No newline at end of file diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/MqttsnClientConnectException.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/MqttsnClientConnectException.java new file mode 100644 index 00000000..0cc60ce0 --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/MqttsnClientConnectException.java @@ -0,0 +1,45 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.client; + +public class MqttsnClientConnectException extends Exception { + public MqttsnClientConnectException() { + } + + public MqttsnClientConnectException(String message) { + super(message); + } + + public MqttsnClientConnectException(String message, Throwable cause) { + super(message, cause); + } + + public MqttsnClientConnectException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClient.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClient.java new file mode 100644 index 00000000..f69083dd --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClient.java @@ -0,0 +1,716 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.client.impl; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.client.MqttsnClientConnectException; +import org.slj.mqtt.sn.client.impl.examples.Example; +import org.slj.mqtt.sn.client.spi.IMqttsnClient; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntime; +import org.slj.mqtt.sn.model.*; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.utils.MqttsnUtils; + +import java.io.IOException; +import java.util.Calendar; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +/** + * Provides a blocking command implementation, with the ability to handle transparent reconnection + * during unsolicited disconnection events. + * + * Publishing occurs asynchronously and is managed by a FIFO queue. The size of the queue is determined + * by the configuration supplied. + * + * Connect, subscribe, unsubscribe and disconnect ( & sleep) are blocking calls which are considered successful on + * receipt of the correlated acknowledgement message. + * + * Management of the sleeping client state can either be supervised by the application, or by the client itself. During + * the sleep cycle, underlying resources (threads) are intelligently started and stopped. For example during sleep, the + * queue processing is closed down, and restarted during the Connected state. + * + * The client is {@link java.io.Closeable}. On close, a remote DISCONNECT operation is run (if required) and all encapsulated + * state (timers and threads) are stopped gracefully at best attempt. Once a client has been closed, it should be discarded and a new instance created + * should a new connection be required. + * + * For example use, please refer to {@link Example}. + */ +public class MqttsnClient extends AbstractMqttsnRuntime implements IMqttsnClient { + + private volatile MqttsnSessionState state; + private volatile int keepAlive; + private volatile boolean cleanSession; + + private volatile int errorRetryCounter = 0; + + private Thread managedConnectionThread = null; + private boolean managedConnection = false; + private volatile boolean autoReconnect = false; + + /** + * Construct a new client instance whose connection is NOT automatically managed. It will be up to the application + * to monitor and manage the connection lifecycle. + * + * If you wish to have the client supervise your connection (including active pings and unsolicited disconnect handling) then you + * should use the constructor which specifies managedConnected = true. + */ + public MqttsnClient(){ + this(false, false); + } + + /** + * Construct a new client instance specifying whether you want to client to automatically handle unsolicited DISCONNECT + * events. + * + * @param managedConnection - You can choose to use managed connections which will actively monitor your connection with the remote gateway, + * and issue PINGS where neccessary to keep your session alive + */ + public MqttsnClient(boolean managedConnection){ + this(managedConnection, true); + } + + /** + * Construct a new client instance specifying whether you want to client to automatically handle unsolicited DISCONNECT + * events. + * + * @param managedConnection - You can choose to use managed connections which will actively monitor your connection with the remote gateway, + * * and issue PINGS where neccessary to keep your session alive + * + * @param autoReconnect - When operating in managedConnection mode, should we attempt to silently reconnect if we detected a dropped + * connection + */ + public MqttsnClient(boolean managedConnection, boolean autoReconnect){ + this.managedConnection = managedConnection; + this.autoReconnect = autoReconnect; + registerConnectionListener(connectionListener); + } + + protected void resetErrorState(){ + errorRetryCounter = 0; + } + + @Override + protected void startupServices(IMqttsnRuntimeRegistry registry) throws MqttsnException { + + try { + Optional optionalContext = registry.getNetworkRegistry().first(); + if(!registry.getOptions().isEnableDiscovery() && + !optionalContext.isPresent()){ + throw new MqttsnRuntimeException("unable to launch non-discoverable client without configured gateway"); + } + } catch(NetworkRegistryException e){ + throw new MqttsnException("error using network registry", e); + } + + callStartup(registry.getMessageStateService()); + callStartup(registry.getMessageHandler()); + callStartup(registry.getMessageQueue()); + callStartup(registry.getMessageRegistry()); + callStartup(registry.getContextFactory()); + callStartup(registry.getSubscriptionRegistry()); + callStartup(registry.getTopicRegistry()); + callStartup(registry.getQueueProcessor()); + callStartup(registry.getTransport()); + } + + @Override + protected void stopServices(IMqttsnRuntimeRegistry registry) throws MqttsnException { + callShutdown(registry.getTransport()); + callShutdown(registry.getQueueProcessor()); + callShutdown(registry.getMessageStateService()); + callShutdown(registry.getMessageHandler()); + callShutdown(registry.getMessageQueue()); + callShutdown(registry.getMessageRegistry()); + callShutdown(registry.getContextFactory()); + callShutdown(registry.getSubscriptionRegistry()); + callShutdown(registry.getTopicRegistry()); + } + + @Override + public boolean isConnected() { + try { + IMqttsnSessionState state = checkSession(false); + return state.getClientState() == MqttsnClientState.CONNECTED; + } catch(MqttsnException e){ + return false; + } + } + + @Override + public boolean isAsleep() { + try { + IMqttsnSessionState state = checkSession(false); + return state.getClientState() == MqttsnClientState.ASLEEP; + } catch(MqttsnException e){ + return false; + } + } + + @Override + /** + * @see {@link IMqttsnClient#connect(int, boolean)} + */ + public void connect(int keepAlive, boolean cleanSession) throws MqttsnException, MqttsnClientConnectException{ + if(!MqttsnUtils.validUInt16(keepAlive)){ + throw new MqttsnExpectationFailedException("invalid keepAlive supplied"); + } + this.keepAlive = keepAlive; + this.cleanSession = cleanSession; + IMqttsnSessionState state = checkSession(false); + synchronized (this) { + //-- its assumed regardless of being already connected or not, if connect is called + //-- local state should be discarded + clearState(state.getContext(), cleanSession); + if (state.getClientState() != MqttsnClientState.CONNECTED) { + startProcessing(false); + try { + IMqttsnMessage message = registry.getMessageFactory().createConnect( + registry.getOptions().getContextId(), keepAlive, false, cleanSession); + MqttsnWaitToken token = registry.getMessageStateService().sendMessage(state.getContext(), message); + Optional response = + registry.getMessageStateService().waitForCompletion(state.getContext(), token); + stateChangeResponseCheck(state, token, response, MqttsnClientState.CONNECTED); + state.setKeepAlive(keepAlive); + startProcessing(true); + } catch(MqttsnExpectationFailedException e){ + //-- something was not correct with the CONNECT, shut it down again + logger.log(Level.WARNING, "error issuing CONNECT, disconnect"); + state.setClientState(MqttsnClientState.DISCONNECTED); + stopProcessing(); + throw new MqttsnClientConnectException(e); + } + } + } + } + + @Override + /** + * @see {@link IMqttsnClient#waitForCompletion(MqttsnWaitToken, int)} + */ + public Optional waitForCompletion(MqttsnWaitToken token, int customWaitTime) throws MqttsnExpectationFailedException { + synchronized (this){ + Optional response = registry.getMessageStateService().waitForCompletion( + state.getContext(), token, customWaitTime); + MqttsnUtils.responseCheck(token, response); + return response; + } + } + + @Override + /** + * @see {@link IMqttsnClient#publish(String, int, byte[])} + */ + public MqttsnWaitToken publish(String topicName, int QoS, byte[] data) throws MqttsnException, MqttsnQueueAcceptException{ + if(!MqttsnUtils.validQos(QoS)){ + throw new MqttsnExpectationFailedException("invalid QoS supplied"); + } + if(!MqttsnUtils.validTopicName(topicName)){ + throw new MqttsnExpectationFailedException("invalid topicName supplied"); + } + + if(QoS == -1){ + Integer alias = registry.getTopicRegistry().lookupPredefined(state.getContext(), topicName); + if(alias == null) + throw new MqttsnExpectationFailedException("can only publish to PREDEFINED topics at QoS -1"); + } + + IMqttsnSessionState state = checkSession(QoS >= 0); + UUID messageId = registry.getMessageRegistry().add(data, getMessageExpiry()); + MqttsnWaitToken token = registry.getMessageQueue().offer(state.getContext(), + new QueuedPublishMessage( + messageId, topicName, QoS)); + return token; + } + + @Override + /** + * @see {@link IMqttsnClient#subscribe(String, int)} + */ + public void subscribe(String topicName, int QoS) throws MqttsnException{ + if(!MqttsnUtils.validTopicName(topicName)){ + throw new MqttsnExpectationFailedException("invalid topicName supplied"); + } + if(!MqttsnUtils.validQos(QoS)){ + throw new MqttsnExpectationFailedException("invalid QoS supplied"); + } + IMqttsnSessionState state = checkSession(true); + + TopicInfo info = registry.getTopicRegistry().lookup(state.getContext(), topicName, true); + IMqttsnMessage message = null; + if(info == null || info.getType() == MqttsnConstants.TOPIC_TYPE.SHORT || + info.getType() == MqttsnConstants.TOPIC_TYPE.NORMAL){ + //-- the spec is ambiguous here; where a normalId has been obtained, it still requires use of + //-- topicName string + message = registry.getMessageFactory().createSubscribe(QoS, topicName); + } + else { + //-- only predefined should use the topicId as an uint16 + message = registry.getMessageFactory().createSubscribe(QoS, info.getType(), info.getTopicId()); + } + + synchronized (this){ + MqttsnWaitToken token = registry.getMessageStateService().sendMessage(state.getContext(), message); + Optional response = registry.getMessageStateService().waitForCompletion(state.getContext(), token); + MqttsnUtils.responseCheck(token, response); + } + } + + @Override + /** + * @see {@link IMqttsnClient#unsubscribe(String)} + */ + public void unsubscribe(String topicName) throws MqttsnException{ + if(!MqttsnUtils.validTopicName(topicName)){ + throw new MqttsnExpectationFailedException("invalid topicName supplied"); + } + IMqttsnSessionState state = checkSession(true); + + TopicInfo info = registry.getTopicRegistry().lookup(state.getContext(), topicName, true); + IMqttsnMessage message = null; + if(info == null || info.getType() == MqttsnConstants.TOPIC_TYPE.SHORT || + info.getType() == MqttsnConstants.TOPIC_TYPE.NORMAL){ + //-- the spec is ambiguous here; where a normalId has been obtained, it still requires use of + //-- topicName string + message = registry.getMessageFactory().createUnsubscribe(topicName); + } + else { + //-- only predefined should use the topicId as an uint16 + message = registry.getMessageFactory().createUnsubscribe(info.getType(), info.getTopicId()); + } + + synchronized (this){ + MqttsnWaitToken token = registry.getMessageStateService().sendMessage(state.getContext(), message); + Optional response = registry.getMessageStateService().waitForCompletion(state.getContext(), token); + MqttsnUtils.responseCheck(token, response); + } + } + + @Override + /** + * @see {@link IMqttsnClient#supervisedSleepWithWake(int, int, int, boolean)} + */ + public void supervisedSleepWithWake(int duration, int wakeAfterIntervalSeconds, int maxWaitTimeMillis, boolean connectOnFinish) + throws MqttsnException, MqttsnClientConnectException { + + if(!MqttsnUtils.validUInt16(duration)){ + throw new MqttsnExpectationFailedException("invalid duration supplied"); + } + + if(!MqttsnUtils.validUInt16(wakeAfterIntervalSeconds)){ + throw new MqttsnExpectationFailedException("invalid wakeAfterInterval supplied"); + } + + if(wakeAfterIntervalSeconds > duration) + throw new MqttsnExpectationFailedException("sleep duration must be greater than the wake after period"); + + long now = System.currentTimeMillis(); + long sleepUntil = now + (duration * 1000); + sleep(duration); + while(sleepUntil > (now = System.currentTimeMillis())){ + long timeLeft = sleepUntil - now; + long period = (int) Math.min(duration, timeLeft / 1000); + //-- sleep for the wake after period + try { + long wake = Math.min(wakeAfterIntervalSeconds, period); + if(wake > 0){ + logger.log(Level.INFO, String.format("waking after [%s] seconds", wake)); + Thread.sleep(wake * 1000); + wake(maxWaitTimeMillis); + } else { + break; + } + } catch(InterruptedException e){ + Thread.currentThread().interrupt(); + throw new MqttsnException(e); + } + } + + if(connectOnFinish){ + IMqttsnSessionState state = checkSession(false); + connect(state.getKeepAlive(), false); + } else { + startProcessing(false); + disconnect(); + } + } + + @Override + /** + * @see {@link IMqttsnClient#sleep(int)} + */ + public void sleep(int duration) throws MqttsnException{ + if(!MqttsnUtils.validUInt16(duration)){ + throw new MqttsnExpectationFailedException("invalid duration supplied"); + } + logger.log(Level.INFO, String.format("sleeping for [%s] seconds", duration)); + IMqttsnSessionState state = checkSession(true); + IMqttsnMessage message = registry.getMessageFactory().createDisconnect(duration); + synchronized (this){ + MqttsnWaitToken token = registry.getMessageStateService().sendMessage(state.getContext(), message); + Optional response = registry.getMessageStateService().waitForCompletion(state.getContext(), token); + stateChangeResponseCheck(state, token, response, MqttsnClientState.ASLEEP); + state.setKeepAlive(0); + clearState(state.getContext(),false); + stopProcessing(); + } + } + + @Override + /** + * @see {@link IMqttsnClient#wake()} + */ + public void wake() throws MqttsnException{ + wake(registry.getOptions().getMaxWait()); + } + + @Override + /** + * @see {@link IMqttsnClient#wake(int)} + */ + public void wake(int waitTime) throws MqttsnException{ + IMqttsnSessionState state = checkSession(false); + IMqttsnMessage message = registry.getMessageFactory().createPingreq(registry.getOptions().getContextId()); + synchronized (this){ + if(MqttsnUtils.in(state.getClientState(), + MqttsnClientState.ASLEEP)){ + startProcessing(false); + MqttsnWaitToken token = registry.getMessageStateService().sendMessage(state.getContext(), message); + state.setClientState(MqttsnClientState.AWAKE); + try { + Optional response = registry.getMessageStateService().waitForCompletion(state.getContext(), token, + waitTime); + stateChangeResponseCheck(state, token, response, MqttsnClientState.ASLEEP); + stopProcessing(); + } catch(MqttsnExpectationFailedException e){ + //-- this means NOTHING was received after my sleep - gateway may have gone, so disconnect and + //-- force CONNECT to be next operation + disconnect(false, false); + throw new MqttsnExpectationFailedException("gateway did not respond to AWAKE state; disconnected"); + } + } else { + throw new MqttsnExpectationFailedException("client cannot wake from a non-sleep state"); + } + } + } + + @Override + /** + * @see {@link IMqttsnClient#ping()} + */ + public void ping() throws MqttsnException{ + IMqttsnSessionState state = checkSession(true); + IMqttsnMessage message = registry.getMessageFactory().createPingreq(registry.getOptions().getContextId()); + synchronized (this){ + MqttsnWaitToken token = registry.getMessageStateService().sendMessage(state.getContext(), message); + registry.getMessageStateService().waitForCompletion(state.getContext(), token); + } + } + + @Override + /** + * @see {@link IMqttsnClient#disconnect()} + */ + public void disconnect() throws MqttsnException { + disconnect(true, true); + } + + private void disconnect(boolean sendRemoteDisconnect, boolean deepClean) throws MqttsnException { + try { + IMqttsnSessionState state = checkSession(false); + synchronized (this) { + if(state != null){ + try { + if (MqttsnUtils.in(state.getClientState(), + MqttsnClientState.CONNECTED, MqttsnClientState.ASLEEP, MqttsnClientState.AWAKE)) { + logger.log(Level.INFO, String.format("disconnecting client; deepClean ? [%s], sending remote disconnect ? [%s]", deepClean, sendRemoteDisconnect)); + clearState(state.getContext(), deepClean); + + if(sendRemoteDisconnect){ + IMqttsnMessage message = registry.getMessageFactory().createDisconnect(); + MqttsnWaitToken token = registry.getMessageStateService().sendMessage(state.getContext(), message); +// Optional response = registry.getMessageStateService().waitForCompletion(state.getContext(), token); +// stateChangeResponseCheck(state, token, response, MqttsnClientState.DISCONNECTED); + } + } + } finally { + state.setClientState(MqttsnClientState.DISCONNECTED); + } + } + } + } finally { + stopProcessing(); + } + } + + @Override + /** + * @see {@link IMqttsnClient#close()} + */ + public void close() throws IOException { + try { + disconnect(); + } catch(MqttsnException e){ + throw new IOException(e); + } finally { + try { + if(registry != null) + stop(); + } + catch(MqttsnException e){ + throw new IOException (e); + } finally { + if(managedConnectionThread != null){ + synchronized (managedConnectionThread){ + managedConnectionThread.notifyAll(); + } + } + } + } + } + + /** + * @see {@link IMqttsnClient#getClientId()} + */ + public String getClientId(){ + return registry.getOptions().getContextId(); + } + + private void stateChangeResponseCheck(IMqttsnSessionState sessionState, MqttsnWaitToken token, Optional response, MqttsnClientState newState) + throws MqttsnExpectationFailedException { + try { + MqttsnUtils.responseCheck(token, response); + if(response.isPresent() && + !response.get().isErrorMessage()){ + sessionState.setClientState(newState); + } + } catch(MqttsnExpectationFailedException e){ + logger.log(Level.SEVERE, "operation could not be completed, error in response"); + throw e; + } + } + + private MqttsnSessionState discoverGatewaySession() throws MqttsnException { + if(state == null){ + synchronized (this){ + if(state == null){ + try { + logger.log(Level.INFO, "discovering gateway..."); + Optional optionalMqttsnContext = + registry.getNetworkRegistry().waitForContext(registry.getOptions().getDiscoveryTime(), TimeUnit.SECONDS); + if(optionalMqttsnContext.isPresent()){ + INetworkContext gatewayContext = optionalMqttsnContext.get(); + state = new MqttsnSessionState(registry.getNetworkRegistry().getSessionContext(gatewayContext), MqttsnClientState.PENDING); + logger.log(Level.INFO, String.format("discovery located a gateway for use [%s]", gatewayContext)); + } else { + throw new MqttsnException("unable to discovery gateway within specified timeout"); + } + } catch(NetworkRegistryException | InterruptedException e){ + throw new MqttsnException("discovery was interrupted and no gateway was found", e); + } + } + } + } + return state; + } + + public int getPingDelta(){ + return (Math.max(keepAlive, 60) / registry.getOptions().getPingDivisor()); + } + + private void activateManagedConnection(){ + if(managedConnectionThread == null){ + managedConnectionThread = new Thread(() -> { + while(running){ + try { + synchronized (managedConnectionThread){ + long delta = errorRetryCounter > 0 ? registry.getOptions().getMaxErrorRetryTime() : getPingDelta() * 1000; + logger.log(Level.FINE, + String.format("managed connection monitor is running at time delta [%s], keepAlive [%s]...", delta, keepAlive)); + managedConnectionThread.wait(delta); + + if(running){ + synchronized (this){ //-- we could receive a unsolicited disconnect during passive reconnection | ping.. + IMqttsnSessionState state = checkSession(false); + if(state != null){ + if(state.getClientState() == MqttsnClientState.DISCONNECTED){ + if(autoReconnect){ + logger.log(Level.INFO, "client connection set to auto-reconnect..."); + connect(keepAlive, false); + resetErrorState(); + } + } + else if(state.getClientState() == MqttsnClientState.CONNECTED){ + if(keepAlive > 0){ //-- keepAlive 0 means alive forever, dont bother pinging + Long lastMessageSent = registry.getMessageStateService(). + getMessageLastSentToContext(state.getContext()); + if(lastMessageSent == null || System.currentTimeMillis() > + lastMessageSent + delta ){ + logger.log(Level.INFO, "managed connection issuing ping..."); + ping(); + resetErrorState(); + } + } + } + } + } + } + } + } catch(Exception e){ + try { + if(errorRetryCounter++ >= registry.getOptions().getMaxErrorRetries()){ + logger.log(Level.SEVERE, String.format("error [%s] on connection manager thread, DISCONNECTING", errorRetryCounter), e); + resetErrorState(); + disconnect(false, true); + } else { + registry.getMessageStateService().clearInflight(getSessionState().getContext()); + logger.log(Level.WARNING, String.format("error [%s] on connection manager thread, execute retransmission", errorRetryCounter), e); + } + } catch(Exception ex){ + logger.log(Level.WARNING, String.format("error handling retranmission [%s] on connection manager thread, execute retransmission", errorRetryCounter), e); + } + } + } + logger.log(Level.INFO, String.format("managed-connection closing down")); + }, "mqtt-sn-managed-connection"); + managedConnectionThread.setPriority(Thread.MIN_PRIORITY); + managedConnectionThread.setDaemon(true); + managedConnectionThread.start(); + } + } + + public void resetConnection(IMqttsnContext context, Throwable t, boolean attemptRestart) { + try { + logger.log(Level.WARNING, String.format("connection lost at transport layer [%s]", context), t); + disconnect(false, false); + //attempt to restart transport + callShutdown(registry.getTransport()); + if(attemptRestart){ + callStartup(registry.getTransport()); + if(managedConnectionThread != null){ + try { + synchronized (managedConnectionThread){ + managedConnectionThread.notify(); + } + } catch(Exception e){ + logger.log(Level.WARNING, String.format("error encountered when trying to recover from unsolicited disconnect [%s]", context, e)); + } + } + } + } catch(Exception e){ + logger.log(Level.WARNING, String.format("error encountered resetting connection [%s], current client state is [%s]", context, getSessionState()), e); + } + } + + private MqttsnSessionState checkSession(boolean validateConnected) throws MqttsnException { + MqttsnSessionState state = discoverGatewaySession(); + if(validateConnected && state.getClientState() != MqttsnClientState.CONNECTED) + throw new MqttsnRuntimeException("client not connected"); + return state; + } + + private final void stopProcessing() throws MqttsnException { + //-- ensure we stop message queue sending when we are not connected + registry.getMessageStateService().unscheduleFlush(state.getContext()); + callShutdown(registry.getMessageHandler()); + callShutdown(registry.getMessageStateService()); + } + + private final void startProcessing(boolean processQueue) throws MqttsnException { + + callStartup(registry.getTransport()); + callStartup(registry.getMessageStateService()); + callStartup(registry.getMessageHandler()); + if(processQueue){ + if(managedConnection){ + activateManagedConnection(); + } + } + } + + private void clearState(IMqttsnContext context, boolean deepClear) throws MqttsnException { + //-- unsolicited disconnect notify to the application + logger.log(Level.INFO, String.format("clearing state, deep clean ? [%s]", deepClear)); + registry.getMessageStateService().clearInflight(context); + registry.getTopicRegistry().clear(context, + registry.getOptions().isSleepClearsRegistrations()); + if(getSessionState() != null) state.setKeepAlive(0); + if(deepClear){ + registry.getSubscriptionRegistry().clear(context); + registry.getTopicRegistry().clear(context, true); +// registry.getMessageQueue().clear(context); + } + } + + private Date getMessageExpiry(){ + Calendar c = Calendar.getInstance(); + c.setTime(new Date()); + c.add(Calendar.HOUR, +1); + return c.getTime(); + } + + public IMqttsnSessionState getSessionState(){ + return state; + } + + protected IMqttsnConnectionStateListener connectionListener = + new IMqttsnConnectionStateListener() { + + @Override + public void notifyConnected(IMqttsnContext context) { + + } + + @Override + public void notifyRemoteDisconnect(IMqttsnContext context) { +// boolean shouldRecover = MqttsnUtils.in(state.getClientState(), +// MqttsnClientState.CONNECTED, MqttsnClientState.ASLEEP, MqttsnClientState.AWAKE); +// resetConnection(context, null, shouldRecover); + try { + disconnect(false, false); + } catch(Exception e){ + + } + } + + @Override + public void notifyActiveTimeout(IMqttsnContext context) { + + } + + @Override + public void notifyLocalDisconnect(IMqttsnContext context, Throwable t) { + + } + + @Override + public void notifyConnectionLost(IMqttsnContext context, Throwable t) { + resetConnection(context, t, true); + } + }; +} \ No newline at end of file diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientMessageHandler.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientMessageHandler.java new file mode 100644 index 00000000..0d5c1436 --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientMessageHandler.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.client.impl; + +import org.slj.mqtt.sn.client.spi.IMqttsnClientRuntimeRegistry; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.impl.AbstractMqttsnMessageHandler; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.wire.version1_2.payload.MqttsnAdvertise; +import org.slj.mqtt.sn.wire.version1_2.payload.MqttsnRegister; + +import java.util.logging.Level; + +public class MqttsnClientMessageHandler + extends AbstractMqttsnMessageHandler { + + @Override + protected IMqttsnMessage handleRegister(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException, MqttsnCodecException { + MqttsnRegister register = (MqttsnRegister) message; + if(register.getTopicId() > 0 && register.getTopicName() != null){ + registry.getTopicRegistry().register(context, register.getTopicName(), register.getTopicId()); + } + return super.handleRegister(context, message); + } + + @Override + protected void beforeHandle(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + } +} diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientRuntimeRegistry.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientRuntimeRegistry.java new file mode 100644 index 00000000..da45c499 --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientRuntimeRegistry.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.client.impl; + +import org.slj.mqtt.sn.client.spi.IMqttsnClientRuntimeRegistry; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.impl.MqttsnContextFactory; +import org.slj.mqtt.sn.impl.MqttsnMessageQueueProcessor; +import org.slj.mqtt.sn.impl.ram.*; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.net.NetworkAddressRegistry; + +public class MqttsnClientRuntimeRegistry extends AbstractMqttsnRuntimeRegistry implements IMqttsnClientRuntimeRegistry { + + public MqttsnClientRuntimeRegistry(MqttsnOptions options){ + super(options); + } + + public static MqttsnClientRuntimeRegistry defaultConfiguration(MqttsnOptions options){ + MqttsnClientRuntimeRegistry registry = (MqttsnClientRuntimeRegistry) new MqttsnClientRuntimeRegistry(options). + withContextFactory(new MqttsnContextFactory()). + withMessageHandler(new MqttsnClientMessageHandler()). + withMessageRegistry(new MqttsnInMemoryMessageRegistry()). + withNetworkAddressRegistry(new NetworkAddressRegistry(options.getMaxNetworkAddressEntries())). + withTopicRegistry(new MqttsnInMemoryTopicRegistry()). + withSubscriptionRegistry(new MqttsnInMemorySubscriptionRegistry()). + withMessageQueue(new MqttsnInMemoryMessageQueue()). + withQueueProcessor(new MqttsnMessageQueueProcessor(true)). + withMessageStateService(new MqttsnInMemoryMessageStateService(true)); + return registry; + } +} diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientUdpOptions.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientUdpOptions.java new file mode 100644 index 00000000..7c5a632b --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/MqttsnClientUdpOptions.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.client.impl; + +import org.slj.mqtt.sn.net.MqttsnUdpOptions; + +public class MqttsnClientUdpOptions extends MqttsnUdpOptions { + { + //-- set all the client ports to wildcard 0 for localhost availability assignment + withPort(DEFAULT_LOCAL_CLIENT_PORT); + withSecurePort(DEFAULT_LOCAL_CLIENT_PORT); + withBroadcastPort(DEFAULT_LOCAL_CLIENT_PORT); + withBindBroadcastListener(true); + } +} diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/ClientInteractiveMain.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/ClientInteractiveMain.java new file mode 100644 index 00000000..14f3e20c --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/ClientInteractiveMain.java @@ -0,0 +1,44 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.client.impl.cli; + +import org.slj.mqtt.sn.net.MqttsnUdpOptions; +import org.slj.mqtt.sn.net.MqttsnUdpTransport; +import org.slj.mqtt.sn.spi.IMqttsnTransport; + +public class ClientInteractiveMain { + public static void main(String[] args) throws Exception { + MqttsnInteractiveClientLauncher.launch(new MqttsnInteractiveClient() { + protected IMqttsnTransport createTransport() { + MqttsnUdpOptions udpOptions = new MqttsnUdpOptions().withMtu(4096).withReceiveBuffer(4096). + withPort(MqttsnUdpOptions.DEFAULT_LOCAL_CLIENT_PORT); + return new MqttsnUdpTransport(udpOptions); + } + }); + } +} diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/MqttsnInteractiveClient.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/MqttsnInteractiveClient.java new file mode 100644 index 00000000..0761f032 --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/MqttsnInteractiveClient.java @@ -0,0 +1,494 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.client.impl.cli; + +import org.slj.mqtt.sn.cli.AbstractInteractiveCli; +import org.slj.mqtt.sn.client.MqttsnClientConnectException; +import org.slj.mqtt.sn.client.impl.MqttsnClient; +import org.slj.mqtt.sn.client.impl.MqttsnClientRuntimeRegistry; +import org.slj.mqtt.sn.codec.MqttsnCodecs; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntime; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.impl.ram.MqttsnInMemoryTopicRegistry; +import org.slj.mqtt.sn.model.MqttsnClientState; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.model.MqttsnQueueAcceptException; +import org.slj.mqtt.sn.model.Subscription; +import org.slj.mqtt.sn.net.NetworkAddress; +import org.slj.mqtt.sn.spi.IMqttsnTransport; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.io.IOException; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +public abstract class MqttsnInteractiveClient extends AbstractInteractiveCli { + + static final String TEST_TOPIC = "test/topic"; + static final int TEST_PAUSE = 500; + static final int[] ALLOWED_QOS = new int[]{-1,0,1,2}; + + enum COMMANDS { + LOOP("Create messages in a loop", new String[]{"int count", "String* topicName", "String* data", "int QoS"}), + STATS("View statistics for runtime", new String[0]), + RESET("Reset the stats and the local queue", new String[0]), + CONNECT("Connect to the gateway and establish a new session", new String[]{"boolean cleanSession", "int16 keepAlive"}), + SUBSCRIBE("Subscribe to a topic", new String[]{"String* topicName", "int QoS"}), + DISCONNECT("Disconnect from the gateway session", new String[0]), + SLEEP("Send the remote session to sleep", new String[]{"int16 duration"}), + WAKE("Wake from a sleep to check for messages", new String[0]), + PUBLISH("Publish a new message", new String[]{"String* topicName", "String* data", "int QoS"}), + UNSUBSCRIBE("Unsubscribe an existing topic subscription", new String[]{"String* topicName"}), + STATUS("Obtain the status of the client", new String[0]), + TEST("Execute an built in test suite", new String[0]), + PREDEFINE("Add a predefined topic alias", new String[]{"String* topicName", "int16 topicAlias"}), + HELP("List this message", new String[0]), + QUIT("Quit the application", new String[0]), + EXIT("Quit the application", new String[0], true), + BYE("Quit the application", new String[0], true); + + private String description; + private String[] arguments; + private boolean hidden = false; + + COMMANDS(String description, String[] arguments, boolean hidden){ + this(description, arguments); + this.hidden = hidden; + } + + COMMANDS(String description, String[] arguments){ + this.description = description; + this.arguments = arguments; + } + + public boolean isHidden() { + return hidden; + } + + public String[] getArguments() { + return arguments; + } + + public String getDescription(){ + return description; + } + } + + protected boolean processCommand(String command) throws Exception { + COMMANDS c = COMMANDS.valueOf(command.toUpperCase()); + processCommand(c); + if(c == COMMANDS.QUIT || c == COMMANDS.BYE || c == COMMANDS.EXIT){ + return false; + } + return true; + } + + protected void processCommand(COMMANDS command) throws IOException { + try { + switch (command){ + case HELP: + for(COMMANDS c : COMMANDS.values()){ + if(c.isHidden()) continue; + StringBuilder sb = new StringBuilder(); + for(String a : c.getArguments()){ + if(sb.length() > 0){ + sb.append(", "); + } + sb.append(a); + } + + output.println("\t" + c.name()); + output.println("\t\t" + c.getDescription()); + } + break; + case CONNECT: + connect( + captureMandatoryBoolean(input, output, "Would you like a clean session?"), + captureMandatoryInt(input, output, "How long would you like your keepAlive to be (in seconds)?", null)); + break; + case SUBSCRIBE: + subscribe( + captureMandatoryString(input, output, "Which topic would you like to subscribe to?"), + captureMandatoryInt(input, output, "At which QoS would you like to subscribe (0,1,2)?", ALLOWED_QOS)); + break; + case UNSUBSCRIBE: + unsubscribe( + captureMandatoryString(input, output, "Which topic would you like to unsubscribe from?")); + break; + case LOOP: + loop( + captureMandatoryInt(input, output, "How many messages would you like to send?", null), + captureMandatoryString(input, output, "Which topic would you like to publish to?"), + captureMandatoryInt(input, output, "At which QoS would you like to publish (-1,0,1,2)?", ALLOWED_QOS)); + break; + case PUBLISH: + publish( + captureMandatoryString(input, output, "Which topic would you like to publish to?"), + captureMandatoryString(input, output, "What is the message you would like to publish?"), + captureMandatoryInt(input, output, "At which QoS would you like to publish (-1,0,1,2)?", ALLOWED_QOS)); + break; + case SLEEP: + sleep(captureMandatoryInt(input, output, "How long would you like to sleep for (in seconds)?", null)); + break; + case WAKE: + wake(); + break; + case STATS: + stats(); + break; + case RESET: + resetMetrics(); + break; + case STATUS: + status(); + break; + case TEST: + test(); + break; + case PREDEFINE: + predefine( + captureMandatoryString(input, output, "What is the topic you would like to predefine?"), + captureMandatoryInt(input, output, "What is the alias for the topic?", null)); + break; + case EXIT: + case BYE: + case QUIT: + quit(); + case DISCONNECT: + disconnect(); + break; + } + } catch(Exception e){ + error( "An error occurred running your command.", e); + } + } + + @Override + protected String getCLIName(){ + return "org slj Mqtt-sn interactive client"; + } + + protected void connect(boolean cleanSession, int keepAlive) + throws IOException, MqttsnException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null && !client.isConnected()){ + try { + resetMetrics(); + client.connect(keepAlive, cleanSession); + message("DONE - connect issued successfully, client is connected"); + } catch(MqttsnClientConnectException e){ + error("Client Reporting Connection Error", e); + } + } else { + message("Client is already connected"); + } + } + + protected void loop(int count, String topicPath, int qos) + throws IOException, MqttsnException { + for (int i = 0; i < count; i++){ + publish(topicPath, "message " + i, qos); + try { + Thread.sleep(1); + } catch(Exception e){} + } + } + + protected void publish(String topicPath, String data, int qos) + throws IOException, MqttsnException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null && (client.isConnected() || qos == -1)){ + try { + client.publish(topicPath, qos, data.getBytes(StandardCharsets.UTF_8)); + if(!client.isConnected()){ + boolean stopAfterUse = false; + try { + if(!getRuntimeRegistry().getQueueProcessor().running()){ + getRuntimeRegistry().getQueueProcessor().start(getRuntimeRegistry()); + stopAfterUse = true; + } + getRuntimeRegistry().getMessageStateService().scheduleFlush( + client.getSessionState().getContext()); + try { + Thread.sleep(1000); + } catch(Exception e){ + } + } finally{ + if(stopAfterUse) getRuntimeRegistry().getQueueProcessor().stop(); + } + message("DONE - message sent and flushed on DISCONNECTED session"); + } + else { + message("DONE - message queued for sending"); + } + } catch(MqttsnQueueAcceptException e){ + error("Client Reporting Queue Accept Error", e); + } + } else { + message("Client must first be connected before issuing this command"); + } + } + + protected void subscribe(String topicPath, int qos) + throws IOException, MqttsnException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null && client.isConnected()){ + client.subscribe(topicPath, qos); + message("DONE - subscribe issued successfully"); + } else { + message("Client must first be connected before issuing this command"); + } + } + + protected void unsubscribe(String topicPath) + throws IOException, MqttsnException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null && client.isConnected()){ + client.unsubscribe(topicPath); + message("DONE - unsubscribe issued successfully"); + } else { + message("Client must first be connected before issuing this command"); + } + } + + protected void sleep(int duration) + throws IOException, MqttsnException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null && client.isConnected()){ + client.sleep(duration); + message(String.format("DONE - client is sleeping for %s seconds", duration)); + } else { + message("Client must first be connected before issuing this command"); + } + } + + protected void wake() + throws IOException, MqttsnException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null && client.isAsleep()){ + client.wake(60000); + message(String.format("DONE - client received all messages and is back sleeping")); + } else { + message("Client must first be asleep before issuing this command"); + } + } + + protected void quit() + throws MqttsnException, IOException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null){ + stop(); + message("Client stopped - bye :-)"); + } + } + + public void stop() + throws MqttsnException, IOException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null){ + client.close(); + } + } + + protected void disconnect() + throws IOException, MqttsnException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null){ + client.disconnect(); + message("DONE - client is disconnected"); + } + } + + @Override + public void resetMetrics() throws IOException { + super.resetMetrics(); + if(runtime != null && runtimeRegistry != null){ + try { + MqttsnClient client = (MqttsnClient) getRuntime(); + runtimeRegistry.getMessageQueue().clear(client.getSessionState().getContext()); + } catch(Exception e){ + error("error clearing queue;", e); + } + } + } + + protected void status() + throws IOException, MqttsnException { + MqttsnClient client = (MqttsnClient) getRuntime(); + + message("Client Id: " + clientId); + if(client != null){ + if(runtime != null) { + message( "Receive Publish Count: " + receiveCount + " ("+receivedPublishBytesCount+" bytes)"); + message( "Sent Publish Count: " + sentCount + " ("+publishedBytesCount+" bytes)"); + if(client.getSessionState() != null){ + message( "Client Started: " + client.getSessionState().getSessionStarted()); + message( "Client Session State: " + getConnectionString(client.getSessionState().getClientState())); + message( "Keep Alive: " + client.getSessionState().getKeepAlive()); + message( "Ping Interval: " + client.getPingDelta() + " seconds"); + + Long lastSent = getRuntimeRegistry().getMessageStateService().getMessageLastSentToContext(client.getSessionState().getContext()); + if(lastSent != null){ + message( "Last Message Sent: " + new Date(lastSent)); + } + + Long lastReceived = getRuntimeRegistry().getMessageStateService().getMessageLastReceivedFromContext(client.getSessionState().getContext()); + if(lastReceived != null){ + message( "Last Message Received: " + new Date(lastReceived)); + } + + if (getRuntimeRegistry().getMessageQueue() != null) { + message( "Publish Queue Size: " + getRuntimeRegistry().getMessageQueue().size(client.getSessionState().getContext())); + } + } + if (getOptions() != null) { + Map pTopics = getOptions().getPredefinedTopics(); + if(pTopics != null){ + message( "Predefined Topic Count: " + pTopics.size()); + Iterator itr = pTopics.keySet().iterator(); + while(itr.hasNext()){ + String topic = itr.next(); + message( "\t" + topic + " = " + pTopics.get(topic)); + } + } + } + + if(client.getSessionState() != null){ + Set subs = getRuntimeRegistry().getSubscriptionRegistry().readSubscriptions(client.getSessionState().getContext()); + Iterator itr = subs.iterator(); + message("Subscription(s): "); + synchronized (subs) { + while (itr.hasNext()) { + Subscription s = itr.next(); + message("\t" + s.getTopicPath() + " -> " + s.getQoS()); + } + } + + if(getRuntimeRegistry().getTopicRegistry() instanceof MqttsnInMemoryTopicRegistry){ + Set s = ((MqttsnInMemoryTopicRegistry)getRuntimeRegistry().getTopicRegistry() ).getAll(client.getSessionState().getContext()); + if(s != null){ + message( "Registered Topic Count: " + s.size()); + for(MqttsnInMemoryTopicRegistry.ConfirmableTopicRegistration t : s){ + message( "\t" + t.getTopicPath() + " = " + t.getAliasId() + " ? " + t.isConfirmed()); + } + } + } + } + + if (getRuntimeRegistry().getQueueProcessor() != null) { + message( "Queue Processor: " + (getRuntimeRegistry().getQueueProcessor().running() ? "Running" : "Stopped")); + } + } + } else { + message( "Client Status: Awaiting Connection.."); + } + } + + protected void test() + throws IOException { + MqttsnClient client = (MqttsnClient) getRuntime(); + if(client != null && !client.isConnected()){ + try { + resetMetrics(); + Thread.sleep(TEST_PAUSE); + connect(true, 60); + Thread.sleep(TEST_PAUSE); + subscribe(TEST_TOPIC, 2); + Thread.sleep(TEST_PAUSE); + publish(TEST_TOPIC, "test qos 0", 0); + publish(TEST_TOPIC, "test qos 1", 1); + publish(TEST_TOPIC, "test qos 2", 2); + Thread.sleep(20000); + disconnect(); + message("Tests have finished"); + } catch(Exception e){ + error("Client Reporting Queue Accept Error", e); + } + } else { + message("Client must first be disconnected before running tests, please issue DISCONNECT command"); + } + } + + @Override + protected MqttsnOptions createOptions() throws UnknownHostException { + return new MqttsnOptions(). + withNetworkAddressEntry("remote-gateway", + NetworkAddress.from(port, hostName)). + withContextId(clientId). + withMaxMessagesInQueue(100000). + withMinFlushTime(0). + withMaxProtocolMessageSize(4096). + withSleepClearsRegistrations(false); + } + + @Override + protected AbstractMqttsnRuntimeRegistry createRuntimeRegistry(MqttsnOptions options, IMqttsnTransport transport) { + AbstractMqttsnRuntimeRegistry registry = MqttsnClientRuntimeRegistry.defaultConfiguration(options). + withTransport(transport). + withCodec(MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2); + return registry; + } + + @Override + protected AbstractMqttsnRuntime createRuntime(AbstractMqttsnRuntimeRegistry registry, MqttsnOptions options) { + MqttsnClient runtime = new MqttsnClient(true, false); + return runtime; + } + + @Override + public void start() throws Exception { + super.start(); + getRuntime().start(getRuntimeRegistry()); + } + + @Override + protected String getPropertyFileName() { + return "client.properties"; + } + + protected String getConnectionString(MqttsnClientState state){ + if(state == null) return "NOINIT"; + switch (state){ + case AWAKE: + return cli_green() + state; + case CONNECTED: + return cli_green() + state; + case ASLEEP: + return cli_blue() + state; + case DISCONNECTED: + return cli_red() + state; + case PENDING: + default: + return cli_reset() + state; + } + } +} diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/MqttsnInteractiveClientLauncher.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/MqttsnInteractiveClientLauncher.java new file mode 100644 index 00000000..f82273b7 --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/cli/MqttsnInteractiveClientLauncher.java @@ -0,0 +1,53 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.client.impl.cli; + +import java.io.PrintStream; +import java.util.Scanner; +import java.util.logging.LogManager; + +public class MqttsnInteractiveClientLauncher { + public static void launch(MqttsnInteractiveClient interactiveClient) throws Exception { + if(!Boolean.getBoolean("debug")) LogManager.getLogManager().reset(); + Scanner input = new Scanner(System.in); + PrintStream output = System.out; + try { + interactiveClient.init(input, output); + interactiveClient.welcome(); + interactiveClient.configureWithHistory(); + interactiveClient.start(); + interactiveClient.command(); + interactiveClient.exit(); + } catch(Exception e){ + System.err.println("A fatal error was encountered: " + e.getMessage()); + } finally { + input.close(); + interactiveClient.stop(); + } + } +} diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/examples/Example.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/examples/Example.java new file mode 100644 index 00000000..457a06d0 --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/impl/examples/Example.java @@ -0,0 +1,109 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.client.impl.examples; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.client.impl.MqttsnClient; +import org.slj.mqtt.sn.client.impl.MqttsnClientRuntimeRegistry; +import org.slj.mqtt.sn.client.impl.MqttsnClientUdpOptions; +import org.slj.mqtt.sn.codec.MqttsnCodecs; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.net.MqttsnUdpOptions; +import org.slj.mqtt.sn.net.MqttsnUdpTransport; +import org.slj.mqtt.sn.net.NetworkAddress; +import org.slj.mqtt.sn.utils.MqttsnUtils; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class Example { + public static void main(String[] args) throws Exception { + + //-- use the client transport options, which will use random unallocated local ports + MqttsnUdpOptions udpOptions = new MqttsnClientUdpOptions(); + + //-- runtimes options can be used to tune the behaviour of the client + MqttsnOptions options = new MqttsnOptions(). + //-- specify the address of any static gateway nominating a context id for it + withNetworkAddressEntry("gatewayId", NetworkAddress.localhost(MqttsnUdpOptions.DEFAULT_LOCAL_PORT)). + //-- configure your clientId + withContextId("clientId1"). + //-- specify and predefined topic Ids that the gateway will know about + withPredefinedTopic("my/predefined/example/topic/1", 1); + + //-- using a default configuration for the controllers will just work out of the box, alternatively + //-- you can supply your own implementations to change underlying storage or business logic as is required + AbstractMqttsnRuntimeRegistry registry = MqttsnClientRuntimeRegistry.defaultConfiguration(options). + withTransport(new MqttsnUdpTransport(udpOptions)). + //-- select the codec you wish to use, support for SN 1.2 is standard or you can nominate your own + withCodec(MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2); + + AtomicInteger receiveCounter = new AtomicInteger(); + CountDownLatch latch = new CountDownLatch(1); + + //-- the client is Closeable and so use a try with resource + try (MqttsnClient client = new MqttsnClient()) { + + //-- the client needs to be started using the configuration you constructed above + client.start(registry); + + //-- register any publish receive listeners you require + client.registerReceivedListener((IMqttsnContext context, String topic, int qos, byte[] data) -> { + receiveCounter.incrementAndGet(); + System.err.println(String.format("received message [%s] [%s]", + receiveCounter.get(), new String(data, MqttsnConstants.CHARSET))); + latch.countDown(); + }); + + //-- register any publish sent listeners you require + client.registerSentListener((IMqttsnContext context, UUID messageId, String topic, int qos, byte[] data) -> { + System.err.println(String.format("sent message [%s]", + new String(data, MqttsnConstants.CHARSET))); + }); + + //-- issue a connect command - the method will block until completion + client.connect(360, true); + + //-- issue a subscribe command - the method will block until completion + client.subscribe("my/example/topic/1", 2); + + //-- issue a publish command - the method will queue the message for sending and return immediately + client.publish("my/example/topic/1", 1, "hello world".getBytes()); + + //-- wait for the sent message to be looped back before closing + latch.await(30, TimeUnit.SECONDS); + + //-- issue a disconnect command - the method will block until completion + client.disconnect(); + } + } +} diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClient.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClient.java new file mode 100644 index 00000000..a59151a5 --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClient.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.client.spi; + +import org.slj.mqtt.sn.client.MqttsnClientConnectException; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.MqttsnQueueAcceptException; +import org.slj.mqtt.sn.model.MqttsnWaitToken; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.utils.MqttsnUtils; + +import java.io.Closeable; +import java.util.Optional; + +/** + * An SN client allows you to talk to a DISCOVERED or PRECONFIGURED Sensor Network gateway. + */ +public interface IMqttsnClient extends Closeable { + + /** + * Is the client in the CONNECTED state + */ + boolean isConnected(); + + /** + * Is the client in the ASLEEP state + */ + boolean isAsleep(); + + /** + * A blocking call to issue a CONNECT packet. On return your client will be considered in ACTIVE mode unless an exception + * is thrown. + * + * @param keepAlive - Time in seconds to keep the session alive before the gateway times you out + * @param cleanSession - Whether tidy up any existing session state on the gateway; including message queues, subscriptions and registrations + * @throws MqttsnException - There was an internal error + * @throws MqttsnClientConnectException - The connect handshake could not be completed + */ + void connect(int keepAlive, boolean cleanSession) throws MqttsnException, MqttsnClientConnectException; + + /** + * Add a new message onto the queue to send to the gateway at some point in the future. + * The queue is processed in FIFO order in the background on a queue processing thread. + * You can be notified of successful completion by registering a messageSent listener + * onto the client. + * + * @param topicName - The path to which you wish to send the data + * @param QoS - Quality of Service of the method, one of -1, 0 , 1, 2 + * @param data - The data you wish to send + * @return token - a sending token that you can use to block on until the message has been sent + * @throws MqttsnException - There was an internal error + * @throws MqttsnQueueAcceptException - The queue could not accept the message, most likely full + */ + MqttsnWaitToken publish(String topicName, int QoS, byte[] data) throws MqttsnException, MqttsnQueueAcceptException; + + + /** + * @see {@link IMqttsnMessageStateService#waitForCompletion} + */ + Optional waitForCompletion(MqttsnWaitToken token, int customWaitTime) throws MqttsnExpectationFailedException; + + + /** + * Subscribe the topic using the most appropriate topic scheme. This will automatically select the use of a PREDEFINED or SHORT + * according to your configuration. If no PREDEFINED or SHORT topic is available, a new NORMAL registration will be placed in your + * topic registry transparently. + * + * @param topicName - The path to subscribe to + * @param QoS - The quality of service of the subscription + * @throws MqttsnException - An error occurred + */ + void subscribe(String topicName, int QoS) throws MqttsnException; + + /** + * Unsubscribe the topic using the most appropriate topic scheme. This will automatically select th use of a PREDEFINED or SHORT + * according to your configuration. If no PREDEFINED or SHORT topic is available, a new registration will be places in your + * topic registry transparently for receiving the messages. + * + * @param topicName - The path to unsubscribe to + * @throws MqttsnException - An error occurred + */ + void unsubscribe(String topicName) throws MqttsnException; + + /** + * A long blocking call which will put your client into the SLEEP state for the {duration} specified in SECONDS, automatically + * waking every {wakeAfterInterval} period of time in SECONDS to check for messages. NOTE: messages queued to be sent while + * the device is SLEEPing will not be processed until the device is back in the ACTIVE status. + * + * @param duration - time in seconds for the sleep period to last + * @param wakeAfterIntervalSeconds - time in seconds that the device will wake up to check for messages, before going back to sleep + * @param maxWaitTimeMillis - time in millis during WAKING that the device will wait for a PINGRESP response from the gateway before erroring + * @param connectOnFinish - when the DURATION period has elapsed, should the device transition into the ACTIVE mode by issuing a soft CONNECT or alternatively, DISCONNECT + * @throws MqttsnException - An error occurred + */ + void supervisedSleepWithWake(int duration, int wakeAfterIntervalSeconds, int maxWaitTimeMillis, boolean connectOnFinish) throws MqttsnException, MqttsnClientConnectException; + + /** + * Put the device into the SLEEP mode for the duration in seconds. NOTE: this is a non-supervized sleep, which means the application + * is responsible for issuing PINGREQ and CONNECTS from this mode + * @param duration - Time in seconds to put the device to sleep. + * @throws MqttsnException - An error occurred + */ + void sleep(int duration) throws MqttsnException; + + /** + * Unsupervised Wake the device up by issuing a PINGREQ from SLEEP state. The maxWait time will be taken from the core client configuration + * supplied during setup. + * @throws MqttsnException - An error occurred + */ + void wake() throws MqttsnException; + + /** + * Unsupervised Wake the device up by issuing a PINGREQ from SLEEP state. + * @param waitTime - Time in MILLISECONDS to wait for a PINGRESP after the AWAKE period + * @throws MqttsnException - An error occurred + */ + void wake(int waitTime) throws MqttsnException; + + /** + * Issue a PINGREQ during CONNECTED mode. This operation will not affect the state of the runtime, with the exception of + * updating the last message sent time timestamp + * @throws MqttsnException - An error occurred + */ + void ping() throws MqttsnException; + + /** + * DISCONNECT from the gateway. Closes down any local queues and active processing + */ + void disconnect() throws MqttsnException; + + /** + * Registers a new Publish listener which will be notified when a PUBLISH message is successfully committed to the gateway + * @param listener - The instance of the listener to notify + */ + void registerSentListener(IMqttsnPublishSentListener listener); + + /** + * Registers a new Publish listener which will be notified when a PUBLISH message is successfully RECEIVED committed from the gateway + * @param listener - The instance of the listener to notify + */ + void registerReceivedListener(IMqttsnPublishReceivedListener listener); + + /** + * Return the clientId associated with this instance. The clientId is passed to the client from the configuration (contextId). + * @return - Return the clientId associated with this instance + */ + String getClientId(); + +} \ No newline at end of file diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClientRuntimeRegistry.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClientRuntimeRegistry.java new file mode 100644 index 00000000..ce6dad64 --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClientRuntimeRegistry.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.client.spi; + +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; + +public interface IMqttsnClientRuntimeRegistry extends IMqttsnRuntimeRegistry { + +} diff --git a/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClientService.java b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClientService.java new file mode 100644 index 00000000..709dd5f3 --- /dev/null +++ b/mqtt-sn-client/src/main/java/org/slj/mqtt/sn/client/spi/IMqttsnClientService.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.client.spi; + +import org.slj.mqtt.sn.spi.IMqttsnService; +import org.slj.mqtt.sn.spi.MqttsnException; + +public interface IMqttsnClientService extends IMqttsnService { + + void start(IMqttsnClientRuntimeRegistry runtime) throws MqttsnException; + + void stop() throws MqttsnException; +} \ No newline at end of file diff --git a/mqtt-sn-client/src/test/java/org/slj/mqtt/sn/client/test/ClientConnectionTest.java b/mqtt-sn-client/src/test/java/org/slj/mqtt/sn/client/test/ClientConnectionTest.java new file mode 100644 index 00000000..4803f43b --- /dev/null +++ b/mqtt-sn-client/src/test/java/org/slj/mqtt/sn/client/test/ClientConnectionTest.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.client.test; + +import org.junit.Assert; +import org.junit.Test; +import org.slj.mqtt.sn.client.MqttsnClientConnectException; +import org.slj.mqtt.sn.client.impl.MqttsnClient; +import org.slj.mqtt.sn.client.impl.MqttsnClientRuntimeRegistry; +import org.slj.mqtt.sn.client.impl.MqttsnClientUdpOptions; +import org.slj.mqtt.sn.codec.MqttsnCodecs; +import org.slj.mqtt.sn.model.IMqttsnSessionState; +import org.slj.mqtt.sn.model.MqttsnClientState; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.net.MqttsnUdpOptions; +import org.slj.mqtt.sn.net.MqttsnUdpTransport; +import org.slj.mqtt.sn.net.NetworkAddress; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +public class ClientConnectionTest { + + static final String TOPIC = "t/%s"; + static final int CONNECT_TIMEOUT = 1000; + static final int MUTLI_CLIENT_LATCH_TIMEOUT = 240; + static final byte[] PAYLOAD = new byte[]{0x01,0x02,0x03}; + + protected MqttsnClientRuntimeRegistry createClientRuntimeRegistry(String clientId){ + MqttsnUdpOptions udpOptions = new MqttsnClientUdpOptions(); + MqttsnOptions options = new MqttsnOptions(). + withNetworkAddressEntry("gatewayId", + NetworkAddress.localhost(MqttsnUdpOptions.DEFAULT_LOCAL_PORT)). + withContextId(clientId + "-" + ThreadLocalRandom.current().nextLong()). + withMaxWait(60000). + withPredefinedTopic("my/example/topic/1", 1); + + return (MqttsnClientRuntimeRegistry) MqttsnClientRuntimeRegistry.defaultConfiguration(options). + withTransport(new MqttsnUdpTransport(udpOptions)). + withCodec(MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2); + } + + @Test + public void testClientConnection() throws IOException, MqttsnException, MqttsnClientConnectException { + try (MqttsnClient client = new MqttsnClient()) { + client.start(createClientRuntimeRegistry("testClientId")); + client.connect(CONNECT_TIMEOUT, true); + assertClientSessionState(client, MqttsnClientState.CONNECTED); + } + } + + @Test + public void testClientDoubleConnection() throws IOException, MqttsnException, MqttsnClientConnectException { + try (MqttsnClient client = new MqttsnClient()) { + client.start(createClientRuntimeRegistry("testClientId")); + client.connect(CONNECT_TIMEOUT, true); + assertClientSessionState(client, MqttsnClientState.CONNECTED); + client.connect(CONNECT_TIMEOUT, true); + assertClientSessionState(client, MqttsnClientState.CONNECTED); + } + } + + @Test + public void testClientDisconnectedAfterClose() throws IOException, MqttsnException, MqttsnClientConnectException { + try (MqttsnClient client = new MqttsnClient()) { + client.start(createClientRuntimeRegistry("testClientId")); + client.connect(CONNECT_TIMEOUT, true); + assertClientSessionState(client, MqttsnClientState.CONNECTED); + client.disconnect(); + assertClientSessionState(client, MqttsnClientState.DISCONNECTED); + } + } + + @Test + public void testClientSleep() throws IOException, MqttsnException, MqttsnClientConnectException { + try (MqttsnClient client = new MqttsnClient()) { + client.start(createClientRuntimeRegistry("testClientId")); + client.connect(CONNECT_TIMEOUT, true); + assertClientSessionState(client, MqttsnClientState.CONNECTED); + client.sleep(CONNECT_TIMEOUT); + assertClientSessionState(client, MqttsnClientState.ASLEEP); + } + } + + @Test + public void testClientWake() throws IOException, MqttsnException, MqttsnClientConnectException { + try (MqttsnClient client = new MqttsnClient()) { + client.start(createClientRuntimeRegistry("testClientId")); + client.connect(CONNECT_TIMEOUT, true); + assertClientSessionState(client, MqttsnClientState.CONNECTED); + client.sleep(CONNECT_TIMEOUT); + assertClientSessionState(client, MqttsnClientState.ASLEEP); + client.wake(15000); + assertClientSessionState(client, MqttsnClientState.ASLEEP); + } + } + + @Test + public void testClientSleepConnect() throws IOException, MqttsnException, MqttsnClientConnectException { + try (MqttsnClient client = new MqttsnClient()) { + client.start(createClientRuntimeRegistry("testClientId")); + client.connect(CONNECT_TIMEOUT, true); + assertClientSessionState(client, MqttsnClientState.CONNECTED); + client.sleep(CONNECT_TIMEOUT); + assertClientSessionState(client, MqttsnClientState.ASLEEP); + client.connect(CONNECT_TIMEOUT, false); + assertClientSessionState(client, MqttsnClientState.CONNECTED); + } + } + + @Test + public void testMultipleClientConnections() throws Exception, MqttsnClientConnectException { + + final int concurrent = 5; + final CountDownLatch latch = new CountDownLatch(concurrent); + final Runnable r = new Runnable() { + @Override + public void run() { + try { + final CountDownLatch localLatch = new CountDownLatch(1); + final byte[] payload = "hello".getBytes(); + try (MqttsnClient client = new MqttsnClient()) { + client.start(createClientRuntimeRegistry("testClientId")); + client.registerReceivedListener((context, topicName, qos, data) -> { + if(Objects.deepEquals(data, payload)){ + latch.countDown(); + localLatch.countDown(); + } + }); + client.connect(CONNECT_TIMEOUT, true); + final String publishTopic = String.format(TOPIC, client.getClientId()); + client.subscribe(publishTopic, 2); + assertClientSessionState(client, MqttsnClientState.CONNECTED); + client.publish(publishTopic,2, + payload); + localLatch.await(MUTLI_CLIENT_LATCH_TIMEOUT, TimeUnit.SECONDS); + } + } catch(Exception e){ + e.printStackTrace(); + } + } + }; + for (int i = 0; i < concurrent; i++){ + Thread t = new Thread(r); + t.start(); + } + + Assert.assertTrue("timedout waiting for all clients", latch.await(MUTLI_CLIENT_LATCH_TIMEOUT, TimeUnit.SECONDS)); + } + + protected void assertClientSessionState(MqttsnClient client, MqttsnClientState state){ + IMqttsnSessionState s = client.getSessionState(); + Assert.assertNotNull("session state should not be null", s); + MqttsnClientState sessionClientState = s.getClientState(); + if(state != null){ + Assert.assertNotNull("client state should not be null", sessionClientState); + Assert.assertEquals("client state should match", state, sessionClientState); + } else { + Assert.assertNull("client state should be null", s.getClientState()); + } + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/README.md b/mqtt-sn-codec/README.md new file mode 100644 index 00000000..a4b737a6 --- /dev/null +++ b/mqtt-sn-codec/README.md @@ -0,0 +1,28 @@ +# mqtt-sn-codec +Full, dependency free java implementation of the MQTT-SN protocol specification. Abstraction of the underlying model to support pluggable wire protocols. + +```java + public class ExampleUsage { + public static void main(String[] args) throws Exception { + + //-- select the version of the protocol you wish to use. The versions + //-- registered on the interface are thread-safe, pre-constructed singletons. + //-- you can also manually construct your codecs and manage their lifecycle if you so desire. + IMqttsnCodec mqttsnVersion1_2 = MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2; + + //-- each codec supplies a message factory allowing convenient construction of messages for use + //-- in your application. + IMqttsnMessageFactory factory = mqttsnVersion1_2.createMessageFactory(); + + //-- construct a connect message with your required configuration + IMqttsnMessage connect = + factory.createConnect("testClientId", 60, false, true); + + //-- encode the connect message for wire transport... + byte[] arr = mqttsnVersion1_2.encode(connect); + + //-- then on the other side of the wire.. + connect = mqttsnVersion1_2.decode(arr); + } + } +``` \ No newline at end of file diff --git a/mqtt-sn-codec/pom.xml b/mqtt-sn-codec/pom.xml new file mode 100644 index 00000000..9c9fac2a --- /dev/null +++ b/mqtt-sn-codec/pom.xml @@ -0,0 +1,61 @@ + + + + + 4.0.0 + + org.slj + mqtt-sn-codec + 1.0.0 + + + UTF-8 + 4.13 + 3.8.1 + 1.8 + 1.8 + + + + + junit + junit + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/ExampleUsage.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/ExampleUsage.java new file mode 100644 index 00000000..8e31df1c --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/ExampleUsage.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn; + +import org.slj.mqtt.sn.codec.MqttsnCodecs; +import org.slj.mqtt.sn.spi.IMqttsnCodec; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.IMqttsnMessageFactory; + +public class ExampleUsage { + + public static void main(String[] args) throws Exception { + + //-- select the version of the protocol you wish to use. The versions + //-- registered on the interface are thread-safe, pre-constructed singletons. + //-- you can also manually construct your codecs and manage their lifecycle if you so desire. + IMqttsnCodec mqttsnVersion1_2 = MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2; + + //-- each codec supplies a message factory allowing convenient construction of messages for use + //-- in your application. + IMqttsnMessageFactory factory = mqttsnVersion1_2.createMessageFactory(); + + //-- construct a connect message with your required configuration + IMqttsnMessage connect = + factory.createConnect("testClientId", 60, false, true); + + System.out.println(connect); + + //-- encode the connect message for wire transport... + byte[] arr = mqttsnVersion1_2.encode(connect); + + //-- then on the other side of the wire.. + connect = mqttsnVersion1_2.decode(arr); + + System.out.println(connect); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/MqttsnConstants.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/MqttsnConstants.java new file mode 100644 index 00000000..dba96d64 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/MqttsnConstants.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +public interface MqttsnConstants { + + String ENCODING = "UTF-8"; + Charset CHARSET = StandardCharsets.UTF_8; + + int USIGNED_MAX_16 = 65535; + int USIGNED_MAX_8 = 255; + + byte TOPIC_NORMAL = 0b00, + TOPIC_PREDEFINED = 0b01, + TOPIC_SHORT = 0b10, + TOPIC_RESERVED = 0b11; + + enum TOPIC_TYPE { + + NORMAL(TOPIC_NORMAL), + PREDEFINED(TOPIC_PREDEFINED), + SHORT(TOPIC_SHORT), + RESERVED(TOPIC_RESERVED); + + byte flag; + + TOPIC_TYPE(byte flag) { + this.flag = flag; + } + + public byte getFlag() { + return flag; + } + } + + int RETURN_CODE_ACCEPTED = 0x00, + RETURN_CODE_REJECTED_CONGESTION = 0x01, + RETURN_CODE_INVALID_TOPIC_ID = 0x02, + RETURN_CODE_SERVER_UNAVAILABLE = 0x03; + + int QoS0 = 0, + QoS1 = 1, + QoS2 = 2, + QoSM1 = -1; + + byte ADVERTISE = 0x00; + byte SEARCHGW = 0x01; + byte GWINFO = 0x02; + byte CONNECT = 0x04; + byte CONNACK = 0x05; + byte WILLTOPICREQ = 0x06; + byte WILLTOPIC = 0x07; + byte WILLMSGREQ = 0x08; + byte WILLMSG = 0x09; + byte REGISTER = 0x0A; + byte REGACK = 0x0B; + byte PUBLISH = 0x0C; + byte PUBACK = 0x0D; + byte PUBCOMP = 0x0E; + byte PUBREC = 0x0F; + byte PUBREL = 0x10; + byte SUBSCRIBE = 0x12; + byte SUBACK = 0x13; + byte UNSUBSCRIBE = 0x14; + byte UNSUBACK = 0x15; + byte PINGREQ = 0x16; + byte PINGRESP = 0x17; + byte DISCONNECT = 0x18; + byte WILLTOPICUPD = 0x1A; + byte WILLTOPICRESP = 0x1B; + byte WILLMSGUPD = 0x1C; + byte WILLMSGRESP = 0x1D; + int ENCAPSMSG = 0xFE; + +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/PublishData.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/PublishData.java new file mode 100644 index 00000000..bb412317 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/PublishData.java @@ -0,0 +1,58 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn; + +public class PublishData { + + String topicPath; + int qos; + byte[] data; + + public PublishData(int qos, byte[] data) { + this.qos = qos; + this.data = data; + } + + public PublishData(String topicPath, int qos, byte[] data) { + this.topicPath = topicPath; + this.qos = qos; + this.data = data; + } + + public String getTopicPath() { + return topicPath; + } + + public int getQos() { + return qos; + } + + public byte[] getData() { + return data; + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/AbstractMqttsnCodec.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/AbstractMqttsnCodec.java new file mode 100644 index 00000000..1ad9dd09 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/AbstractMqttsnCodec.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.codec; + +import org.slj.mqtt.sn.spi.IMqttsnCodec; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.payload.AbstractMqttsnMessage; + +/** + * Base class for simple codec implementations. This version will only support message + * types defined by the local abstract, the marker interfaces allow support for any + * message implementations. + * + * @author Simon Johnson + */ +public abstract class AbstractMqttsnCodec implements IMqttsnCodec { + + @Override + public IMqttsnMessage decode(byte[] data) throws MqttsnCodecException { + IMqttsnMessage msg = createInstance(data); + if (!AbstractMqttsnMessage.class.isAssignableFrom(msg.getClass())) + throw new MqttsnCodecException("unsupported message formats in codec"); + ((AbstractMqttsnMessage) msg).decode(data); + return msg; + } + + @Override + public byte[] encode(IMqttsnMessage msg) throws MqttsnCodecException { + if (!AbstractMqttsnMessage.class.isAssignableFrom(msg.getClass())) + throw new MqttsnCodecException("unsupported message formats in codec"); + return ((AbstractMqttsnMessage) msg).encode(); + } + + protected void validateLengthEquals(byte[] data, int length) throws MqttsnCodecException { + if (data.length != length) { + throw new MqttsnCodecException( + String.format("invalid data length %s, must be %s bytes", data.length, length)); + } + } + + protected void validateLengthGreaterThanOrEquals(byte[] data, int length) throws MqttsnCodecException { + if (data.length < length) { + throw new MqttsnCodecException( + String.format("invalid data length %s, must be gt or eq to %s bytes", data.length, length)); + } + } + + @Override + public String print(IMqttsnMessage message) throws MqttsnCodecException { + return MqttsnWireUtils.toBinary(encode(message)); + } + + protected abstract IMqttsnMessage createInstance(byte[] data) throws MqttsnCodecException; +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/AbstractMqttsnMessageFactory.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/AbstractMqttsnMessageFactory.java new file mode 100644 index 00000000..eba8c0ec --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/AbstractMqttsnMessageFactory.java @@ -0,0 +1,200 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.codec; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.IMqttsnMessageFactory; + +public abstract class AbstractMqttsnMessageFactory implements IMqttsnMessageFactory { + + @Override + public IMqttsnMessage createAdvertise(int gatewayId, int duration) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createSearchGw(int radius) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createGwinfo(int gatewayId, String gatewayAddress) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createConnect(String clientId, int keepAlive, boolean willPrompt, boolean cleanSession) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createConnack(int returnCode) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createWillTopicReq() throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createWillTopic(int QoS, boolean retain, String topicPath) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createWillTopicResp(int returnCode) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createWillTopicupd(int QoS, boolean retain, String topicPath) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createWillMsgupd(byte[] payload) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createWillMsgReq() throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createWillMsg(byte[] payload) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createWillMsgResp(int returnCode) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createRegister(String topicPath) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createRegister(int topicAlias, String topicPath) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createRegack(int topicAlias, int returnCode) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createPublish(int QoS, boolean DUP, boolean retain, MqttsnConstants.TOPIC_TYPE type, int topicId, byte[] payload) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createPublish(int QoS, boolean DUP, boolean retain, String topicPath, byte[] payload) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createPuback(int topicId, int returnCode) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createPubrec() throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createPubrel() throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createPubcomp() throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createSubscribe(int QoS, MqttsnConstants.TOPIC_TYPE type, int topicId) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createSubscribe(int QoS, String topicName) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createSuback(int grantedQoS, int topicId, int returnCode) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createUnsubscribe(MqttsnConstants.TOPIC_TYPE type, int topicId) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createUnsubscribe(String topicName) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createUnsuback() throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createPingreq(String clientId) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createPingresp() throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createDisconnect() throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createDisconnect(int duration) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } + + @Override + public IMqttsnMessage createEncapsulatedMessage(String wirelessNodeId, int radius, byte[] messageData) throws MqttsnCodecException { + throw new UnsupportedOperationException("message not supported by codec"); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/MqttsnCodecException.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/MqttsnCodecException.java new file mode 100644 index 00000000..6b37caa8 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/MqttsnCodecException.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.codec; + +public class MqttsnCodecException extends RuntimeException { + + public MqttsnCodecException() { + } + + public MqttsnCodecException(String message) { + super(message); + } + + public MqttsnCodecException(String message, Throwable cause) { + super(message, cause); + } + + public MqttsnCodecException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/MqttsnCodecs.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/MqttsnCodecs.java new file mode 100644 index 00000000..d711efe1 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/codec/MqttsnCodecs.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.codec; + +import org.slj.mqtt.sn.spi.IMqttsnCodec; +import org.slj.mqtt.sn.wire.version1_2.Mqttsn_v1_2_Codec; + +public interface MqttsnCodecs { + + /** + * Version 1.2 support of the MQTT-SN specification + * + * @see Mqttsn specification at http://www.mqtt.org/new/wp-content/uploads/2009/06/MQTT-SN_spec_v1.2.pdf + */ + IMqttsnCodec MQTTSN_CODEC_VERSION_1_2 = new Mqttsn_v1_2_Codec(); + +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnCodec.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnCodec.java new file mode 100644 index 00000000..29fef816 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnCodec.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.PublishData; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +/** + * A codec contains all the functionality to marshall and unmarshall + * wire traffic in the format specified by the implementation. Further, + * it also provides a message factory instance which allows construction + * of wire messages hiding the underlying transport format. This allows versioned + * protocol support. + * + * @author Simon Johnson + */ +public interface IMqttsnCodec { + + /** + * @return - Does the message represent a PUBLISH + */ + PublishData getData(IMqttsnMessage message); + + /** + * @return - Does the message represent a PUBLISH + */ + boolean isPublish(IMqttsnMessage message); + + /** + * @return - Does the message represent a PUBACK + */ + boolean isPuback(IMqttsnMessage message); + + /** + * @return - Does the message represent a PUBREL + */ + boolean isPubRel(IMqttsnMessage message); + + /** + * @return - Does the message represent a PUBREC + */ + boolean isPubRec(IMqttsnMessage message); + + /** + * @return - Does the message represent a DISCONNECT + */ + boolean isDisconnect(IMqttsnMessage message); + + /** + * Is the message considered an ACTIVE message, that is a message actively sent by an application + * (anything other than a PINGREQ, PINGRESP, DISCONNECT) + * @param message - the message to consider + * @return Is the message considered an ACTIVE message + */ + boolean isActiveMessage(IMqttsnMessage message); + + /** + * To help with debugging, this method will return a binary or hex + * representation of the encoded message + */ + String print(IMqttsnMessage message) throws MqttsnCodecException; + + /** + * Given data of the wire, will convert to the message model which can be used + * in a given runtime + * + * @throws MqttsnCodecException - something went wrong when decoding the data + */ + IMqttsnMessage decode(byte[] data) throws MqttsnCodecException; + + /** + * When supplied with messages constructed from an associated message factory, + * will encode them into data that can be sent on the wire + * + * @throws MqttsnCodecException - something went wrong when encoding the data + */ + byte[] encode(IMqttsnMessage message) throws MqttsnCodecException; + + /** + * A message factory will contruct messages using convenience methods + * that hide the complexity of the underlying wire format + */ + IMqttsnMessageFactory createMessageFactory(); + + /** + * Using the first few bytes of a message, determine the length of the full message, + * for use with stream reading + */ + int readMessageSize(byte[] arr) throws MqttsnCodecException; +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnIdentificationPacket.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnIdentificationPacket.java new file mode 100644 index 00000000..dcc35e79 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnIdentificationPacket.java @@ -0,0 +1,6 @@ +package org.slj.mqtt.sn.spi; + +public interface IMqttsnIdentificationPacket { + + String getClientId(); +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessage.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessage.java new file mode 100644 index 00000000..280cdc7e --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessage.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.slj.mqtt.sn.spi; + + +import java.io.Serializable; + +/** + * Represents a wire message that can be manipulated by a gateway OR client for transport by + * changing its msgId. + * + * @author Simon Johnson + * @see Mqttsn specification at http://www.mqtt.org/new/wp-content/uploads/2009/06/MQTT-SN_spec_v1.2.pdf + */ +public interface IMqttsnMessage extends Serializable { + + /** + * Get a user friendly version of the Message object. + * For example; + * "MqttsnConnect" + * "MqttsnDisconnect" + */ + String getMessageName(); + + /** + * If the underlying message uses a msg id as part of its contract + */ + boolean needsMsgId(); + + void setMsgId(int msgId); + + int getMsgId(); + + int getMessageType(); + + int getReturnCode(); + + boolean isErrorMessage(); +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageFactory.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageFactory.java new file mode 100644 index 00000000..4f28773d --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageFactory.java @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +/** + * Use to obtain instances of the codec messages for use in a client or gateway runtime. + * The lightweight abstraction allows message implementations & versions to be pluggable + * into 3rd party systems. + * + * @author Simon Johnson + * @see mqttsn specification at http://www.mqtt.org/new/wp-content/uploads/2009/06/MQTT-SN_spec_v1.2.pdf + */ +public interface IMqttsnMessageFactory { + + /** + * The ADVERTISE message is broadcasted periodically by a gateway to advertise its presence. + * The time interval until the next broadcast time is indicated in the Duration field of this message. + * Its format is illustrated in Table 6 of the specification. + * + * @Param gatewayId: the id of the gateway which sends this message. + * @Param: duration time interval until the next ADVERTISE is broadcasted by this gateway. + */ + IMqttsnMessage createAdvertise(int gatewayId, int duration) + throws MqttsnCodecException; + + /** + * The SEARCHGW message is broadcasted by a client when it searches for a GW. The broadcast radius + * of the SEARCHGW is limited and depends on the density of the clients deployment, e.g. only 1-hop + * broadcast in case of a very dense network in which every MQTT-SN client is reachable from each + * other within 1-hop transmission. + * + * @param radius: the broadcast radius of this message. + * The broadcast radius is also indicated to the underlying network layer when MQTT-SN gives this message for transmission. + */ + IMqttsnMessage createSearchGw(int radius) + throws MqttsnCodecException; + + /** + * The GWINFO message is sent as response to a SEARCHGW message using the broadcast service of the underlying layer, + * with the radius as indicated in the SEARCHGW message. If sent by a GW, it contains only the id of the sending GW; + * otherwise, if sent by a client, it also includes the address of the GW. + * + * @param gatewayId: the id of a GW. + * @param gatewayAddress: address of the indicated GW; optional, only included if message is sent by a client. + * Like the SEARCHGW message the broadcast radius for this message is also indicated to the underlying + * network layer when MQTT-SN gives this message for transmission. + */ + IMqttsnMessage createGwinfo(int gatewayId, String gatewayAddress) + throws MqttsnCodecException; + + /** + * The CONNECT message is sent by a client to setup a connection. + * + * @param clientId: same as with MQTT, contains the client id which is a 1-23 character long string which uniquely identifies the client to the server. + * @param keepAlive: same as with MQTT, contains the value of the Keep Alive timer. + * @param willPrompt: if set, indicates that client is requesting for Will topic and Will message prompting. + * @param cleanSession: same meaning as with MQTT, however extended for Will topic and Will message. + */ + IMqttsnMessage createConnect(String clientId, int keepAlive, boolean willPrompt, boolean cleanSession) + throws MqttsnCodecException; + + /** + * The CONNACK message is sent by the server in response to a connection request from a client. + *

+ * Allowed Return code(s): + * 0x00 - Accepted + * 0x01 - Rejected: congestion + * 0x02 - Rejected: invalid topic ID + * 0x03 - Rejected: not supported reserved + * 0x04 - 0xFF: reserved + * + * @param returnCode: see above + */ + IMqttsnMessage createConnack(int returnCode) + throws MqttsnCodecException; + + /** + * The WILLTOPICREQ message is sent by the GW to request a client for sending the Will topic name. + */ + IMqttsnMessage createWillTopicReq() + throws MqttsnCodecException; + + /** + * The WILLTOPIC message is sent by a client as response to the WILLTOPICREQ message for transferring + * its Will topic name to the GW. + *

+ * An empty WILLTOPIC message is a WILLTOPIC message without Flags and WillTopic field (i.e. it is exactly 2 octets long). + * It is used by a client to delete the Will topic and the Will message stored in the server, see Section 6.4. + * + * @param QoS: same as MQTT, contains the Will QoS + * @param retain: same as MQTT, contains the Will Retain flag – Will: not used + * @param topicPath: contains the Will topic name. + */ + IMqttsnMessage createWillTopic(int QoS, boolean retain, String topicPath) + throws MqttsnCodecException; + + /** + * The WILLTOPICRESP message is sent by a GW to acknowledge the receipt and processing of an WILLTOPICUPD message. + *

+ * Allowed Return code(s): + * 0x00 - Accepted + * 0x01 - Rejected: congestion + * 0x02 - Rejected: invalid topic ID + * 0x03 - Rejected: not supported reserved + * 0x04 - 0xFF: reserved + * + * @param returnCode: see above + */ + IMqttsnMessage createWillTopicResp(int returnCode) + throws MqttsnCodecException; + + /** + * The WILLTOPICUPD message is sent by a client to update its Will topic name stored in the GW/server. + * An empty WILLTOPICUPD message is a WILLTOPICUPD message without Flags and WillTopic field (i.e. + * it is exactly 2 octets long). It is used by a client to delete its Will topic and Will message stored in the GW/server. + * + * @param QoS same as MQTT, contains the Will QoS + * @param retain same as MQTT, contains the Will Retain flag – Will: not used + * @param topicPath contains the Will topic name. + */ + IMqttsnMessage createWillTopicupd(int QoS, boolean retain, String topicPath) + throws MqttsnCodecException; + + /** + * The WILLMSGUPD message is sent by a client to update its Will message stored in the GW/server. + * + * @param payload contains the Will message. + */ + IMqttsnMessage createWillMsgupd(byte[] payload) + throws MqttsnCodecException; + + /** + * The WILLMSGREQ message is sent by the GW to request a client for sending the Will message. + */ + IMqttsnMessage createWillMsgReq() + throws MqttsnCodecException; + + /** + * The WILLMSG message is sent by a client as response to a WILLMSGREQ for transferring its Will message to the GW. + * + * @param payload: contains the Will message. + */ + IMqttsnMessage createWillMsg(byte[] payload) + throws MqttsnCodecException; + + /** + * The WILLMSGRESP message is sent by a GW to acknowledge the receipt and processing of an WILLMSGUPD message. + *

+ * Allowed Return code(s): + * 0x00 - Accepted + * 0x01 - Rejected: congestion + * 0x02 - Rejected: invalid topic ID + * 0x03 - Rejected: not supported reserved + * 0x04 - 0xFF: reserved + * + * @param returnCode: see above + */ + IMqttsnMessage createWillMsgResp(int returnCode) + throws MqttsnCodecException; + + /** + * The REGISTER message is sent by a client to a GW for requesting a topic id value for the included topic name. + * It is also sent by a GW to inform a client about the topic id value it has assigned to the included topic name. + * + * @param topicPath: contains the topic name. + */ + IMqttsnMessage createRegister(String topicPath) + throws MqttsnCodecException; + + /** + * The REGISTER message is sent by a client to a GW for requesting a topic id value for the included topic name. + * It is also sent by a GW to inform a client about the topic id value it has assigned to the included topic name. + * + * @param topicAlias: if sent by a client, it is coded 0x0000 and is not relevant; if sent by a GW, it contains the topic id + * value assigned to the topic name included in the TopicName field; + * @param topicPath: contains the topic name. + */ + IMqttsnMessage createRegister(int topicAlias, String topicPath) + throws MqttsnCodecException; + + /** + * The REGACK message is sent by a client or by a GW as an acknowledgment to the receipt and processing of a REGISTER message. + *

+ * Allowed Return code(s): + * 0x00 - Accepted + * 0x01 - Rejected: congestion + * 0x02 - Rejected: invalid topic ID + * 0x03 - Rejected: not supported reserved + * 0x04 - 0xFF: reserved + * + * @param topicAlias: the value that shall be used as topic id in the PUBLISH messages; + * @param returnCode + */ + IMqttsnMessage createRegack(int topicAlias, int returnCode) + throws MqttsnCodecException; + + + /** + * This message is used by both clients and gateways to publish data for a certain topic. + * + * @param DUP: same as MQTT, indicates whether message is sent for the first time or not. + * @param QoS: same as MQTT, contains the QoS level for this PUBLISH message. + * @param retain: same as MQTT, contains the Retain flag. + * @param type: indicates the type of the topic id contained in the TopicId field. + * @param topicId: contains the topic id value or the short topic name for which the data is published. + * @param payload: the published data + */ + IMqttsnMessage createPublish(int QoS, boolean DUP, boolean retain, MqttsnConstants.TOPIC_TYPE type, int topicId, byte[] payload) + throws MqttsnCodecException; + + /** + * This message is used by both clients and gateways to publish data for topics with short names + * + * @param DUP: same as MQTT, indicates whether message is sent for the first time or not. + * @param QoS: same as MQTT, contains the QoS level for this PUBLISH message. + * @param retain: same as MQTT, contains the Retain flag. + * @param topicPath: the SHORT topic path - version 1.2 does not support full topicNames in the publish + * @param payload: the published data + */ + IMqttsnMessage createPublish(int QoS, boolean DUP, boolean retain, String topicPath, byte[] payload) throws MqttsnCodecException; + + /** + * The PUBACK message is sent by a gateway or a client as an acknowledgment to the receipt and processing + * of a PUBLISH message in case of QoS levels 1 or 2. It can also be sent as response to a PUBLISH message + * in case of an error; the error reason is then indicated in the ReturnCode field. + *

+ * Allowed Return code(s): + * 0x00 - Accepted + * 0x01 - Rejected: congestion + * 0x02 - Rejected: invalid topic ID + * 0x03 - Rejected: not supported reserved + * 0x04 - 0xFF: reserved + * + * @param topicId: same value the one contained in the corresponding PUBLISH message. + * @param returnCode - see table + */ + IMqttsnMessage createPuback(int topicId, int returnCode) + throws MqttsnCodecException; + + /** + * As with MQTT, the PUBREC, PUBREL, and PUBCOMP messages are used in conjunction with a PUBLISH message with QoS level 2. + */ + IMqttsnMessage createPubrec() + throws MqttsnCodecException; + + /** + * As with MQTT, the PUBREC, PUBREL, and PUBCOMP messages are used in conjunction with a PUBLISH message with QoS level 2. + */ + IMqttsnMessage createPubrel() + throws MqttsnCodecException; + + /** + * As with MQTT, the PUBREC, PUBREL, and PUBCOMP messages are used in conjunction with a PUBLISH message with QoS level 2. + */ + IMqttsnMessage createPubcomp() + throws MqttsnCodecException; + + /** + * The SUBSCRIBE message is used by a client to subscribe to a certain topic name. + * NB: this method should ONLY be used to set topicAlias to either PREDEFINED or NORMAL + * + * @param QoS: same as MQTT, contains the requested QoS level for this topic. + * @param topicId: topic id + */ + IMqttsnMessage createSubscribe(int QoS, MqttsnConstants.TOPIC_TYPE type, int topicId) + throws MqttsnCodecException; + + /** + * The SUBSCRIBE message is used by a client to subscribe to a certain topic name. + * + * @param QoS: same as MQTT, contains the requested QoS level for this topic. + * @param topicName: topic path to subscribe to + */ + IMqttsnMessage createSubscribe(int QoS, String topicName) + throws MqttsnCodecException; + + /** + * The SUBACK message is sent by a gateway to a client as an acknowledgment to the receipt and processing of a SUBSCRIBE message. + *

+ * Allowed Return code(s): + * 0x00 - Accepted + * 0x01 - Rejected: congestion + * 0x02 - Rejected: invalid topic ID + * 0x03 - Rejected: not supported reserved + * 0x04 - 0xFF: reserved + * + * @param grantedQoS: same as MQTT, contains the granted QoS level. + * @param topicId: in case of "accepted" the value that will be used as topicid by the gateway when sending PUBLISH messages to the client + * (not relevant in case of subscriptions to a short topic name or to a topic name which contains wildcard characters) + * @param returnCode - see table + */ + IMqttsnMessage createSuback(int grantedQoS, int topicId, int returnCode) + throws MqttsnCodecException; + + /** + * An UNSUBSCRIBE message is sent by the client to the GW to unsubscribe from named topics. + * + * @param type: indicates the type of the topic id contained in the TopicId field. + * @param topicId the topicAlias + */ + IMqttsnMessage createUnsubscribe(MqttsnConstants.TOPIC_TYPE type, int topicId) + throws MqttsnCodecException; + + /** + * An UNSUBSCRIBE message is sent by the client to the GW to unsubscribe from named topics. + * + * @param topicName the topicName + */ + IMqttsnMessage createUnsubscribe(String topicName) + throws MqttsnCodecException; + + /** + * An UNSUBACK message is sent by a GW to acknowledge the receipt and processing of an UNSUBSCRIBE + */ + IMqttsnMessage createUnsuback() + throws MqttsnCodecException; + + /** + * As with MQTT, the PINGREQ message is an ”are you alive” message that is sent from or received by a connected client. + * + * @param clientId: contains the client id; this field is optional and is included by a "sleeping" client when + * it goes to the "awake" state and is waiting for messages sent by the server/gateway. + */ + IMqttsnMessage createPingreq(String clientId) + throws MqttsnCodecException; + + /** + * As with MQTT, a PINGRESP message is the response to a PINGREQ message and means ”yes I am alive”. Keep Alive messages + * flow in either direction, sent either by a connected client or the gateway. + * Moreover, a PINGRESP message is sent by a gateway to inform a sleeping client that it has no more buffered messages + * for that client. + */ + IMqttsnMessage createPingresp() + throws MqttsnCodecException; + + /** + * As with MQTT, the DISCONNECT message is sent by a client to indicate that it wants to close the connection. + * The gateway will acknowledge the receipt of that message by returning a DISCONNECT to the client. + * A server or gateway may also sends a DISCONNECT to a client, e.g. in case a gateway, due to an + * error, cannot map a received message to a client. Upon receiving such a DISCONNECT message, + * a client should try to setup the connection again by sending a CONNECT message to the gateway or server. + * In all these cases the DISCONNECT message does not contain the Duration field. + */ + IMqttsnMessage createDisconnect() + throws MqttsnCodecException; + + /** + * A DISCONNECT message with a Duration field is sent by a client when it wants to go to the "asleep" state. + * The receipt of this message is also acknowledged by the gateway by means of a DISCONNECT message + * (without a duration field). + * + * @param duration - length of sleeping session + */ + IMqttsnMessage createDisconnect(int duration) + throws MqttsnCodecException; + + /** + * Encapsulate message for use on forwarders. + * + * @param wirelessNodeId - Wireless Node Id: identifies the wireless node which has sent or should receive the encapsulated MQTT-SN message. + * The mapping between this Id and the address of the wireless node is implemented by the forwarder, if needed. + * @param radius - broadcast radius (only relevant in direction GW to forwarder) + * @param messageData - the MQTT-SN message to forward + */ + IMqttsnMessage createEncapsulatedMessage(String wirelessNodeId, int radius, byte[] messageData) + throws MqttsnCodecException; +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/MqttsnWireUtils.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/MqttsnWireUtils.java new file mode 100644 index 00000000..4a605429 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/MqttsnWireUtils.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnWireUtils { + + private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + + public static void validate8Bit(int field) throws MqttsnCodecException { + if (field < 0 || field > MqttsnConstants.USIGNED_MAX_8) { + throw new MqttsnCodecException("invalid unsigned 8 bit number - " + field); + } + } + + public static void validate16Bit(int field) throws MqttsnCodecException { + if (field < 0 || field > MqttsnConstants.USIGNED_MAX_16) { + throw new MqttsnCodecException("invalid unsigned 16 bit number - " + field); + } + } + + public static void validateQoS(int QoS) throws MqttsnCodecException { + if (QoS != MqttsnConstants.QoSM1 && + QoS != MqttsnConstants.QoS0 && + QoS != MqttsnConstants.QoS1 && + QoS != MqttsnConstants.QoS2) { + throw new MqttsnCodecException("invalid QoS number - " + QoS); + } + } + + public static int read8bit(byte b1) { + return (b1 & 0xFF); + } + + public static int read16bit(byte b1, byte b2) { + return ((b1 & 0xFF) << 8) + (b2 & 0xFF); + } + + public static String toBinary(byte... b) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; b != null && i < b.length; i++) { + sb.append(String.format("%8s", Integer.toBinaryString(b[i] & 0xFF)).replace(' ', '0')); + if (i < b.length - 1) + sb.append(" "); + } + return sb.toString(); + } + + public static String toHex(byte... b) { + if(b == null) return null; + char[] hexChars = new char[b.length * 2]; + for (int j = 0; j < b.length; j++) { + int v = b[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars); + } + + public static byte[] readBuffer(byte[] buf, int off, int len){ + int copyLength = Math.min(len, buf.length); + byte[] copy = new byte[copyLength]; + System.arraycopy(buf, off, copy, 0, copyLength); + return copy; + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/Mqttsn_v1_2_Codec.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/Mqttsn_v1_2_Codec.java new file mode 100644 index 00000000..fcfb76ac --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/Mqttsn_v1_2_Codec.java @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.PublishData; +import org.slj.mqtt.sn.codec.AbstractMqttsnCodec; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.IMqttsnMessageFactory; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.payload.*; + +public class Mqttsn_v1_2_Codec extends AbstractMqttsnCodec { + + protected IMqttsnMessageFactory messageFactory; + + @Override + public PublishData getData(IMqttsnMessage message) { + MqttsnPublish publish = (MqttsnPublish) message ; + return new PublishData(publish.getQoS(), publish.getData()); + } + + @Override + public boolean isDisconnect(IMqttsnMessage message) { + return message instanceof MqttsnDisconnect; + } + + @Override + public boolean isPublish(IMqttsnMessage message) { + return message instanceof MqttsnPublish; + } + + @Override + public boolean isPuback(IMqttsnMessage message) { + return message instanceof MqttsnPuback; + } + + @Override + public boolean isPubRel(IMqttsnMessage message) { + return message instanceof MqttsnPubrel; + } + + @Override + public boolean isPubRec(IMqttsnMessage message) { + return message instanceof MqttsnPubrec; + } + + @Override + public boolean isActiveMessage(IMqttsnMessage message) { + return ! (message instanceof MqttsnPingreq || + message instanceof MqttsnDisconnect || message instanceof MqttsnPingresp); + } + + @Override + public int readMessageSize(byte[] data) throws MqttsnCodecException { + if (data == null || data.length == 0) + throw new MqttsnCodecException("malformed mqtt-sn packet, need at least 1 byte for sizing"); + return readMessageLength(data); + } + + protected AbstractMqttsnMessage createInstance(byte[] data) throws MqttsnCodecException { + + if (data == null || data.length < 2) + throw new MqttsnCodecException("malformed mqtt-sn packet"); + + AbstractMqttsnMessage msg = null; + int msgType = readMessageType(data); + + switch (msgType) { + case MqttsnConstants.ADVERTISE: + validateLengthGreaterThanOrEquals(data, 3); + msg = new MqttsnAdvertise(); + break; + case MqttsnConstants.SEARCHGW: + validateLengthEquals(data, 3); + msg = new MqttsnSearchGw(); + break; + case MqttsnConstants.GWINFO: + validateLengthGreaterThanOrEquals(data, 3); + msg = new MqttsnGwInfo(); + break; + case MqttsnConstants.CONNECT: + validateLengthGreaterThanOrEquals(data, 6); + msg = new MqttsnConnect(); + break; + case MqttsnConstants.CONNACK: + validateLengthEquals(data, 3); + msg = new MqttsnConnack(); + break; + case MqttsnConstants.REGISTER: + validateLengthGreaterThanOrEquals(data, 7); + msg = new MqttsnRegister(); + break; + case MqttsnConstants.REGACK: + validateLengthEquals(data, 7); + msg = new MqttsnRegack(); + break; + case MqttsnConstants.PUBLISH: + validateLengthGreaterThanOrEquals(data, 7); + msg = new MqttsnPublish(); + msg.decode(data); + break; + case MqttsnConstants.PUBACK: + validateLengthEquals(data, 7); + msg = new MqttsnPuback(); + break; + case MqttsnConstants.PUBCOMP: + validateLengthEquals(data, 4); + msg = new MqttsnPubcomp(); + break; + case MqttsnConstants.PUBREC: + validateLengthEquals(data, 4); + msg = new MqttsnPubrec(); + break; + case MqttsnConstants.PUBREL: + validateLengthEquals(data, 4); + msg = new MqttsnPubrel(); + break; + case MqttsnConstants.PINGREQ: + validateLengthGreaterThanOrEquals(data, 2); + msg = new MqttsnPingreq(); + break; + case MqttsnConstants.PINGRESP: + validateLengthEquals(data, 2); + msg = new MqttsnPingresp(); + break; + case MqttsnConstants.DISCONNECT: + validateLengthGreaterThanOrEquals(data, 2); + msg = new MqttsnDisconnect(); + break; + case MqttsnConstants.SUBSCRIBE: + validateLengthGreaterThanOrEquals(data, 6); + msg = new MqttsnSubscribe(); + break; + case MqttsnConstants.SUBACK: + validateLengthEquals(data, 8); + msg = new MqttsnSuback(); + break; + case MqttsnConstants.UNSUBSCRIBE: + validateLengthGreaterThanOrEquals(data, 6); + msg = new MqttsnUnsubscribe(); + break; + case MqttsnConstants.UNSUBACK: + validateLengthEquals(data, 4); + msg = new MqttsnUnsuback(); + break; + case MqttsnConstants.WILLTOPICREQ: + validateLengthEquals(data, 2); + msg = new MqttsnWilltopicreq(); + break; + case MqttsnConstants.WILLTOPIC: + validateLengthGreaterThanOrEquals(data, 3); + msg = new MqttsnWilltopic(); + break; + case MqttsnConstants.WILLMSGREQ: + validateLengthEquals(data, 2); + msg = new MqttsnWillmsgreq(); + break; + case MqttsnConstants.WILLMSG: + validateLengthGreaterThanOrEquals(data, 2); + msg = new MqttsnWillmsg(); + break; + case MqttsnConstants.WILLTOPICUPD: + validateLengthGreaterThanOrEquals(data, 3); + msg = new MqttsnWilltopicudp(); + break; + case MqttsnConstants.WILLTOPICRESP: + validateLengthEquals(data, 3); + msg = new MqttsnWilltopicresp(); + break; + case MqttsnConstants.WILLMSGUPD: + validateLengthGreaterThanOrEquals(data, 2); + msg = new MqttsnWillmsgupd(); + break; + case MqttsnConstants.WILLMSGRESP: + validateLengthEquals(data, 3); + msg = new MqttsnWillmsgresp(); + break; + case MqttsnConstants.ENCAPSMSG: + validateLengthGreaterThanOrEquals(data, 5); + msg = new MqttsnEncapsmsg(); + break; + default: + throw new MqttsnCodecException(String.format("unknown message type [%s]", msgType)); + } + return msg; + } + + public static int readMessageType(byte[] data) { + int msgType; + if (isExtendedMessage(data)) { + msgType = (data[3] & 0xFF); + } else { + msgType = (data[1] & 0xFF); + } + return msgType; + } + + public static byte readHeaderByteWithOffset(byte[] data, int index) { + return isExtendedMessage(data) ? data[index + 2] : data[index]; + } + + public static boolean isExtendedMessage(byte[] data) { + return data[0] == 0x01; + } + + public static int readMessageLength(byte[] data) { + int length = 0; + if (isExtendedMessage(data)) { + //big payload + length = ((data[1] & 0xFF) << 8) + (data[2] & 0xFF); + } else { + //small payload + length = (data[0] & 0xFF); + } + return length; + } + + @Override + public IMqttsnMessageFactory createMessageFactory() { + if (messageFactory == null) { + synchronized (this) { + if (messageFactory == null) messageFactory = Mqttsn_v1_2_MessageFactory.getInstance(); + } + } + return messageFactory; + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/Mqttsn_v1_2_MessageFactory.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/Mqttsn_v1_2_MessageFactory.java new file mode 100644 index 00000000..59e12130 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/Mqttsn_v1_2_MessageFactory.java @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.AbstractMqttsnMessageFactory; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.IMqttsnMessageFactory; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.payload.*; + +public class Mqttsn_v1_2_MessageFactory extends AbstractMqttsnMessageFactory implements IMqttsnMessageFactory { + + //singleton + private static Mqttsn_v1_2_MessageFactory instance; + + private Mqttsn_v1_2_MessageFactory() { + } + + public static IMqttsnMessageFactory getInstance() { + if (instance == null) { + synchronized (Mqttsn_v1_2_MessageFactory.class) { + if (instance == null) instance = new Mqttsn_v1_2_MessageFactory(); + } + } + return instance; + } + + @Override + public IMqttsnMessage createAdvertise(int gatewayId, int duration) throws MqttsnCodecException { + + MqttsnWireUtils.validate8Bit(gatewayId); + MqttsnWireUtils.validate16Bit(duration); + + MqttsnAdvertise msg = new MqttsnAdvertise(); + msg.setGatewayId(gatewayId); + msg.setDuration(duration); + return msg; + } + + @Override + public IMqttsnMessage createSearchGw(int radius) throws MqttsnCodecException { + + MqttsnWireUtils.validate8Bit(radius); + + MqttsnSearchGw msg = new MqttsnSearchGw(); + msg.setRadius(radius); + return msg; + } + + @Override + public IMqttsnMessage createGwinfo(int gatewayId, String gatewayAddress) throws MqttsnCodecException { + + MqttsnWireUtils.validate8Bit(gatewayId); + + MqttsnGwInfo msg = new MqttsnGwInfo(); + msg.setGatewayId(gatewayId); + msg.setGatewayAddress(gatewayAddress); + return msg; + } + + @Override + public IMqttsnMessage createConnect(String clientId, int keepAlive, boolean willPrompt, boolean cleanSession) throws MqttsnCodecException { + + MqttsnWireUtils.validate16Bit(keepAlive); + + MqttsnConnect msg = new MqttsnConnect(); + msg.setClientId(clientId); + msg.setDuration(keepAlive); + msg.setProtocolId(0); + msg.setCleanSession(cleanSession); + msg.setWill(willPrompt); + return msg; + } + + @Override + public IMqttsnMessage createConnack(int returnCode) throws MqttsnCodecException { + + MqttsnWireUtils.validate8Bit(returnCode); + + MqttsnConnack msg = new MqttsnConnack(); + msg.setReturnCode(returnCode); + return msg; + } + + @Override + public IMqttsnMessage createWillTopicReq() throws MqttsnCodecException { + MqttsnWilltopicreq msg = new MqttsnWilltopicreq(); + return msg; + } + + @Override + public IMqttsnMessage createWillTopic(int QoS, boolean retain, String topicPath) throws MqttsnCodecException { + MqttsnWilltopic msg = new MqttsnWilltopic(); + msg.setQoS(QoS); + msg.setRetainedPublish(retain); + msg.setWillTopic(topicPath); + return msg; + } + + @Override + public IMqttsnMessage createWillTopicResp(int returnCode) throws MqttsnCodecException { + MqttsnWilltopicresp msg = new MqttsnWilltopicresp(); + msg.setReturnCode(returnCode); + return msg; + } + + @Override + public IMqttsnMessage createWillTopicupd(int QoS, boolean retain, String topicPath) throws MqttsnCodecException { + MqttsnWilltopicudp msg = new MqttsnWilltopicudp(); + msg.setQoS(QoS); + msg.setRetainedPublish(retain); + msg.setWillTopic(topicPath); + return msg; + } + + @Override + public IMqttsnMessage createWillMsgupd(byte[] payload) throws MqttsnCodecException { + MqttsnWillmsgupd msg = new MqttsnWillmsgupd(); + msg.setMsgData(payload); + return msg; + } + + @Override + public IMqttsnMessage createWillMsgReq() throws MqttsnCodecException { + MqttsnWillmsgreq msg = new MqttsnWillmsgreq(); + return msg; + } + + @Override + public IMqttsnMessage createWillMsg(byte[] payload) throws MqttsnCodecException { + MqttsnWillmsg msg = new MqttsnWillmsg(); + msg.setMsgData(payload); + return msg; + } + + @Override + public IMqttsnMessage createWillMsgResp(int returnCode) throws MqttsnCodecException { + + MqttsnWireUtils.validate8Bit(returnCode); + + MqttsnWillmsgresp msg = new MqttsnWillmsgresp(); + msg.setReturnCode(returnCode); + return msg; + } + + @Override + public IMqttsnMessage createRegister(int topicAlias, String topicPath) throws MqttsnCodecException { + + MqttsnWireUtils.validate16Bit(topicAlias); + + MqttsnRegister msg = new MqttsnRegister(); + msg.setTopicId(topicAlias); + msg.setTopicName(topicPath); + return msg; + } + + @Override + public IMqttsnMessage createRegister(String topicPath) throws MqttsnCodecException { + MqttsnRegister msg = new MqttsnRegister(); + msg.setTopicName(topicPath); + return msg; + } + + @Override + public IMqttsnMessage createRegack(int topicAlias, int returnCode) throws MqttsnCodecException { + + MqttsnWireUtils.validate8Bit(returnCode); + MqttsnWireUtils.validate16Bit(topicAlias); + + MqttsnRegack msg = new MqttsnRegack(); + msg.setTopicId(topicAlias); + msg.setReturnCode(returnCode); + return msg; + } + + @Override + public IMqttsnMessage createPublish(int QoS, boolean DUP, boolean retain, MqttsnConstants.TOPIC_TYPE type, int topicId, byte[] payload) throws MqttsnCodecException { + + MqttsnWireUtils.validate16Bit(topicId); + + MqttsnPublish msg = new MqttsnPublish(); + msg.setQoS(QoS); + msg.setDupRedelivery(DUP); + msg.setRetainedPublish(retain); + msg.setData(payload); + switch (type) { + case NORMAL: + msg.setNormalTopicAlias(topicId); + break; + case PREDEFINED: + msg.setPredefinedTopicAlias(topicId); + break; + case SHORT: + byte[] topicData = new byte[2]; + topicData[0] = (byte) ((topicId >> 8) & 0xFF); + topicData[1] = (byte) (topicId & 0xFF); + msg.setTopicName(new String(topicData)); + break; + default: + throw new MqttsnCodecException("publish method only supports predefined and normal topic id types"); + } + return msg; + } + + @Override + public IMqttsnMessage createPublish(int QoS, boolean DUP, boolean retain, String topicPath, byte[] payload) throws MqttsnCodecException { + + int length = topicPath.getBytes(MqttsnConstants.CHARSET).length; + if (length > 2) + throw new MqttsnCodecException(String.format("invalid short topic supplied [%s] > 2", length)); + MqttsnPublish msg = new MqttsnPublish(); + msg.setQoS(QoS); + msg.setDupRedelivery(DUP); + msg.setRetainedPublish(retain); + msg.setData(payload); + msg.setTopicName(topicPath); + return msg; + } + + @Override + public IMqttsnMessage createPuback(int topicId, int returnCode) throws MqttsnCodecException { + + MqttsnWireUtils.validate8Bit(returnCode); + MqttsnWireUtils.validate16Bit(topicId); + + MqttsnPuback msg = new MqttsnPuback(); + msg.setTopicId(topicId); + msg.setReturnCode(returnCode); + return msg; + } + + @Override + public IMqttsnMessage createPubrec() throws MqttsnCodecException { + MqttsnPubrec msg = new MqttsnPubrec(); + return msg; + } + + @Override + public IMqttsnMessage createPubrel() throws MqttsnCodecException { + MqttsnPubrel msg = new MqttsnPubrel(); + return msg; + } + + @Override + public IMqttsnMessage createPubcomp() throws MqttsnCodecException { + MqttsnPubcomp msg = new MqttsnPubcomp(); + return msg; + } + + @Override + public IMqttsnMessage createSubscribe(int QoS, MqttsnConstants.TOPIC_TYPE type, int topicId) throws MqttsnCodecException { + + MqttsnWireUtils.validateQoS(QoS); + MqttsnWireUtils.validate16Bit(topicId); + + MqttsnSubscribe msg = new MqttsnSubscribe(); + msg.setQoS(QoS); + switch (type) { + case NORMAL: + msg.setNormalTopicAlias(topicId); + break; + case PREDEFINED: + msg.setPredefinedTopicAlias(topicId); + break; +// case SHORT: +// msg.setTopicName(topicId); +// break; + default: + throw new MqttsnCodecException("subscribe method only supports predefined and normal topic id types"); + } + return msg; + } + + @Override + public IMqttsnMessage createSubscribe(int QoS, String topicName) throws MqttsnCodecException { + + MqttsnWireUtils.validateQoS(QoS); + MqttsnSubscribe msg = new MqttsnSubscribe(); + msg.setQoS(QoS); + msg.setTopicName(topicName); + return msg; + } + + @Override + public IMqttsnMessage createSuback(int grantedQoS, int topicId, int returnCode) throws MqttsnCodecException { + + MqttsnWireUtils.validateQoS(grantedQoS); + MqttsnWireUtils.validate16Bit(topicId); + MqttsnWireUtils.validate8Bit(returnCode); + + MqttsnSuback msg = new MqttsnSuback(); + msg.setQoS(grantedQoS); + msg.setTopicId(topicId); + msg.setReturnCode(returnCode); + return msg; + } + + @Override + public IMqttsnMessage createUnsubscribe(MqttsnConstants.TOPIC_TYPE type, int topicId) throws MqttsnCodecException { + + MqttsnWireUtils.validate16Bit(topicId); + MqttsnUnsubscribe msg = new MqttsnUnsubscribe(); + switch (type) { + case NORMAL: + msg.setNormalTopicAlias(topicId); + break; + case PREDEFINED: + msg.setPredefinedTopicAlias(topicId); + break; + default: + throw new MqttsnCodecException("subscribe method only supports predefined and normal topic id types"); + } + return msg; + } + + @Override + public IMqttsnMessage createUnsubscribe(String topicName) throws MqttsnCodecException { + MqttsnUnsubscribe msg = new MqttsnUnsubscribe(); + msg.setTopicName(topicName); + return msg; + } + + @Override + public IMqttsnMessage createUnsuback() throws MqttsnCodecException { + MqttsnUnsuback msg = new MqttsnUnsuback(); + return msg; + } + + @Override + public IMqttsnMessage createPingreq(String clientId) throws MqttsnCodecException { + MqttsnPingreq msg = new MqttsnPingreq(); + msg.setClientId(clientId); + return msg; + } + + @Override + public IMqttsnMessage createPingresp() throws MqttsnCodecException { + MqttsnPingresp msg = new MqttsnPingresp(); + return msg; + } + + @Override + public IMqttsnMessage createDisconnect() throws MqttsnCodecException { + MqttsnDisconnect msg = new MqttsnDisconnect(); + return msg; + } + + @Override + public IMqttsnMessage createDisconnect(int duration) throws MqttsnCodecException { + + MqttsnWireUtils.validate16Bit(duration); + MqttsnDisconnect msg = new MqttsnDisconnect(); + msg.setDuration(duration); + return msg; + } + + @Override + public IMqttsnMessage createEncapsulatedMessage(String wirelessNodeId, int radius, byte[] messageData) throws MqttsnCodecException { + + MqttsnWireUtils.validate8Bit(radius); + MqttsnEncapsmsg msg = new MqttsnEncapsmsg(); + msg.setEncapsulatedMsg(messageData); + msg.setRadius(radius); + msg.setWirelessNodeId(wirelessNodeId); + return msg; + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessage.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessage.java new file mode 100644 index 00000000..3a6eff38 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessage.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.Mqttsn_v1_2_Codec; + +public abstract class AbstractMqttsnMessage implements IMqttsnMessage { + + protected final int messageType; + + /* The MsgId field is 2-octet long and corresponds to the MQTT ‘Message ID’ parameter. It allows the sender to + match a message with its corresponding acknowledgment. */ + protected int msgId; + + protected int returnCode; + + public AbstractMqttsnMessage() { + messageType = getMessageType(); + } + + public int getMsgId() { + return msgId; + } + + public void setMsgId(int msgId) { + if (!needsMsgId()) throw new MqttsnCodecException("unable to set msg id on message type"); + this.msgId = msgId; + } + + public int getReturnCode() { + return returnCode; + } + + public void setReturnCode(int returnCode) { + this.returnCode = returnCode; + } + + public boolean isErrorMessage() { + return returnCode != MqttsnConstants.RETURN_CODE_ACCEPTED; + } + + /** + * Reads the remaining body from the data allowing for a header size defined in its smallest + * form for convenience but adjusted if the data is an extended type ie. > 255 bytes + */ + protected byte[] readRemainingBytesFromIndexAdjusted(byte[] data, int headerSize) { + int offset = (Mqttsn_v1_2_Codec.isExtendedMessage(data) ? headerSize + 2 : headerSize); + return readBytesFromIndexAdjusted(data, headerSize, data.length - offset); + } + + /** + * Reads the number of bytes from the data allowing for a header size defined in its smallest + * form for convenience but adjusted if the data is an extended type ie. > 255 bytes + */ + protected byte[] readBytesFromIndexAdjusted(byte[] data, int headerSize, int length) { + int size = Mqttsn_v1_2_Codec.readMessageLength(data); + int offset = (Mqttsn_v1_2_Codec.isExtendedMessage(data) ? headerSize + 2 : headerSize); + byte[] msgData = new byte[length]; + System.arraycopy(data, offset, msgData, 0, length); + return msgData; + } + + /** + * Reads an 8 bit field from the array starting at the given index (which will be auto-adjusted if the + * data is an extended type ie. > 255 bytes) + */ + protected int read8BitAdjusted(byte[] data, int startIdx) { + return MqttsnWireUtils.read8bit(Mqttsn_v1_2_Codec.readHeaderByteWithOffset(data, startIdx)); + } + + /** + * Reads a 16 bit field from the array starting at the given index (which will be auto-adjusted if the + * data is an extended type ie. > 255 bytes) + */ + protected int read16BitAdjusted(byte[] data, int startIdx) { + return MqttsnWireUtils.read16bit( + Mqttsn_v1_2_Codec.readHeaderByteWithOffset(data, startIdx), + Mqttsn_v1_2_Codec.readHeaderByteWithOffset(data, startIdx + 1)); + } + + public String getMessageName() { + return getClass().getSimpleName(); + } + + public boolean needsMsgId() { + return false; + } + + public abstract int getMessageType(); + + public abstract void decode(byte[] data) throws MqttsnCodecException; + + public abstract byte[] encode() throws MqttsnCodecException; + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(getMessageName()); + if (needsMsgId()) { + sb.append('{').append(getMsgId()).append("}"); + } + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessageWithFlagsField.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessageWithFlagsField.java new file mode 100644 index 00000000..01c12386 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessageWithFlagsField.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + + +public abstract class AbstractMqttsnMessageWithFlagsField extends AbstractMqttsnMessage { + + /* set to “0” if message is sent for the first time; set to “1” if retransmitted (only relevant within PUBLISH messages); */ + boolean dupRedelivery = false; + + /* meaning as with MQTT for QoS level 0, 1, and 2; set to “0b00” for QoS level 0, + “0b01” for QoS level1, “0b10” for QoS level 2, and “0b11” for new QoS level -1 */ + int QoS; + + /* same meaning as with MQTT (only relevant within PUBLISH messages); */ + boolean retainedPublish = false; + + /* if set, indicates that client is asking for Will topic and Will message prompting + (only relevant within CONNECT message) */ + boolean will = false; + + /* same meaning as with MQTT, however extended for Will topic and Will message + (only relevant within CONNECT message); */ + boolean cleanSession = false; + + /* indicates whether the field TopicId or TopicName included in this message contains a normal topic id + (set to “0b00”), a pre-defined topic id (set to “0b01”), or a short topic name (set to “0b10”). + The value “0b11” is reserved. */ + int topicType; + + public boolean isDupRedelivery() { + return dupRedelivery; + } + + public void setDupRedelivery(boolean dupRedelivery) { + this.dupRedelivery = dupRedelivery; + } + + public int getQoS() { + return QoS == 3 ? -1 : QoS; + } + + public void setQoS(int qoS) { + if (qoS != MqttsnConstants.QoSM1 && + qoS != MqttsnConstants.QoS0 && + qoS != MqttsnConstants.QoS1 && + qoS != MqttsnConstants.QoS2) { + throw new IllegalArgumentException("unable to set invalid QoS value on message " + qoS); + } + QoS = qoS; + } + + public boolean isRetainedPublish() { + return retainedPublish; + } + + public void setRetainedPublish(boolean retainedPublish) { + this.retainedPublish = retainedPublish; + } + + public boolean isWill() { + return will; + } + + public void setWill(boolean will) { + this.will = will; + } + + public boolean isCleanSession() { + return cleanSession; + } + + public void setCleanSession(boolean cleanSession) { + this.cleanSession = cleanSession; + } + + public int getTopicType() { + return topicType; + } + + protected void setTopicType(byte topicType) { + if (topicType != MqttsnConstants.TOPIC_PREDEFINED && + topicType != MqttsnConstants.TOPIC_NORMAL && + topicType != MqttsnConstants.TOPIC_SHORT && + topicType != MqttsnConstants.TOPIC_RESERVED) { + throw new IllegalArgumentException("unable to set invalid topicIdType value on message " + topicType); + } + this.topicType = topicType; + } + + protected void readFlags(byte v) { + /** + DUP QoS Retain Will CleanSession TopicIdType + (bit 7) (6,5) (4) (3) (2) (1,0) + **/ + + //error redelivery + dupRedelivery = ((v & 0x80) >> 7 != 0); + + //qos + QoS = (v & 0x60) >> 5; + + //retained publish + retainedPublish = ((v & 0x10) >> 4 != 0); + + //will + will = ((v & 0x08) >> 3 != 0); + + //clean session + cleanSession = ((v & 0x04) >> 2 != 0); + + //topic type + topicType = (v & 0x03); + } + + protected byte writeFlags() { + /** + DUP QoS Retain Will CleanSession TopicIdType + (bit 7) (6,5) (4) (3) (2) (1,0) + **/ + + byte v = 0x00; + + //dup redelivery + if (dupRedelivery) v |= 0x80; + + //qos + if (QoS == MqttsnConstants.QoS1) v |= 0x20; + else if (QoS == MqttsnConstants.QoS2) v |= 0x40; + else if (QoS == MqttsnConstants.QoSM1) v |= 0x60; + + //retained publish + if (retainedPublish) v |= 0x10; + + //will message + if (will) v |= 0x08; + + //is clean session + if (cleanSession) v |= 0x04; + + //topic type + if (topicType == MqttsnConstants.TOPIC_PREDEFINED) v |= 0x01; + else if (topicType == MqttsnConstants.TOPIC_SHORT) v |= 0x02; + + return v; + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessageWithTopicData.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessageWithTopicData.java new file mode 100644 index 00000000..b447ad77 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnMessageWithTopicData.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; + +public abstract class AbstractMqttsnMessageWithTopicData extends AbstractMqttsnMessageWithFlagsField { + + protected byte[] topicData; + + public String getTopicName() { + if (topicType == MqttsnConstants.TOPIC_PREDEFINED) + throw new IllegalStateException("unable to parse string data from predefined topic alias"); + return new String(topicData, MqttsnConstants.CHARSET); + } + + public void setTopicName(String topicName) { + setTopicType(topicName != null && topicName.length() <= 2 ? MqttsnConstants.TOPIC_SHORT : MqttsnConstants.TOPIC_NORMAL); + topicData = topicName.getBytes(MqttsnConstants.CHARSET); + } + + public void setPredefinedTopicAlias(int topicAlias) { + setTopicType(MqttsnConstants.TOPIC_PREDEFINED); + setTopicAliasId(topicAlias); + } + + public void setNormalTopicAlias(int topicAlias) { + setTopicType(MqttsnConstants.TOPIC_NORMAL); + setTopicAliasId(topicAlias); + } + + public int readTopicDataAsInteger() { + return MqttsnWireUtils.read16bit(topicData[0], topicData[1]); + } + + public byte[] getTopicData() { + return topicData; + } + + protected void setTopicData(byte[] data) { + topicData = data; + } + + protected void setTopicAliasId(int topicAlias) { + topicData = new byte[2]; + topicData[0] = (byte) ((topicAlias >> 8) & 0xFF); + topicData[1] = (byte) (topicAlias & 0xFF); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnPublishMessageConfirmation.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnPublishMessageConfirmation.java new file mode 100644 index 00000000..84a3910f --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnPublishMessageConfirmation.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public abstract class AbstractMqttsnPublishMessageConfirmation extends AbstractMqttsnMessage { + + public boolean needsMsgId() { + return true; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + msgId = read16BitAdjusted(data, 2); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] data = new byte[4]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + data[2] = (byte) ((msgId >> 8) & 0xFF); + data[3] = (byte) (msgId & 0xFF); + return data; + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnSimpleMessage.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnSimpleMessage.java new file mode 100644 index 00000000..65a9d457 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnSimpleMessage.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public abstract class AbstractMqttsnSimpleMessage extends AbstractMqttsnMessage { + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] data = new byte[2]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + return data; + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnSubscribeUnsubscribe.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnSubscribeUnsubscribe.java new file mode 100644 index 00000000..b7616371 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnSubscribeUnsubscribe.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.Mqttsn_v1_2_Codec; + +public abstract class AbstractMqttsnSubscribeUnsubscribe extends AbstractMqttsnMessageWithTopicData { + + public boolean needsMsgId() { + return true; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + readFlags(Mqttsn_v1_2_Codec.readHeaderByteWithOffset(data, 2)); + msgId = read16BitAdjusted(data, 3); + topicData = readRemainingBytesFromIndexAdjusted(data, 5); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] msg; + int length = 5 + topicData.length; + int idx = 0; + + if ((length) > 0xFF) { + length += 2; + msg = new byte[length]; + msg[idx++] = (byte) 0x01; + msg[idx++] = ((byte) (0xFF & (length >> 8))); + msg[idx++] = ((byte) (0xFF & length)); + } else { + msg = new byte[length]; + msg[idx++] = (byte) length; + } + + msg[idx++] = (byte) getMessageType(); + msg[idx++] = writeFlags(); + + msg[idx++] = (byte) ((msgId >> 8) & 0xFF); + msg[idx++] = (byte) (msgId & 0xFF); + + if (topicData != null && topicData.length > 0) { + System.arraycopy(topicData, 0, msg, idx, topicData.length); + } + + return msg; + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillMessage.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillMessage.java new file mode 100644 index 00000000..afa68589 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillMessage.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public abstract class AbstractMqttsnWillMessage extends AbstractMqttsnMessage { + + protected byte[] msgData; + + public byte[] getMsgData() { + return msgData; + } + + public void setMsgData(byte[] msgData) { + this.msgData = msgData; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + if (data.length > 2) { + msgData = readRemainingBytesFromIndexAdjusted(data, 2); + } + } + + @Override + public byte[] encode() throws MqttsnCodecException { + byte[] data = new byte[2 + (msgData == null ? 0 : msgData.length)]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + + if (msgData != null && msgData.length > 0) { + System.arraycopy(msgData, 0, data, 2, msgData.length); + } + + return data; + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillTopicMessage.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillTopicMessage.java new file mode 100644 index 00000000..f9238c96 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillTopicMessage.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.Mqttsn_v1_2_Codec; + +public abstract class AbstractMqttsnWillTopicMessage extends AbstractMqttsnMessageWithFlagsField { + + byte[] willTopicData; + + public String getWillTopicData() { + return willTopicData != null && willTopicData.length > 0 ? new String(willTopicData, MqttsnConstants.CHARSET) : null; + } + + public void setWillTopic(String willTopic) { + this.willTopicData = willTopic.getBytes(MqttsnConstants.CHARSET); + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + readFlags(Mqttsn_v1_2_Codec.readHeaderByteWithOffset(data, 2)); + willTopicData = readRemainingBytesFromIndexAdjusted(data, 3); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] msg; + int length = 3 + willTopicData.length; + int idx = 0; + + if ((length) > 0xFF) { + length += 2; + msg = new byte[length]; + msg[idx++] = (byte) 0x01; + msg[idx++] = ((byte) (0xFF & (length >> 8))); + msg[idx++] = ((byte) (0xFF & length)); + } else { + msg = new byte[length]; + msg[idx++] = (byte) length; + } + + msg[idx++] = (byte) getMessageType(); + msg[idx++] = writeFlags(); + + if (willTopicData != null && willTopicData.length > 0) { + System.arraycopy(willTopicData, 0, msg, idx, willTopicData.length); + } + + return msg; + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillresp.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillresp.java new file mode 100644 index 00000000..2c8527a4 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/AbstractMqttsnWillresp.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public abstract class AbstractMqttsnWillresp extends AbstractMqttsnMessage { + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + setReturnCode(read8BitAdjusted(data, 2)); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] data = new byte[3]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + data[2] = (byte) getReturnCode(); + return data; + } + +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnAdvertise.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnAdvertise.java new file mode 100644 index 00000000..0e02a3bd --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnAdvertise.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnAdvertise extends AbstractMqttsnMessage { + + protected int gatewayId; + protected int duration; + + public int getGatewayId() { + return gatewayId; + } + + public void setGatewayId(int gatewayId) { + this.gatewayId = gatewayId; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + @Override + public int getMessageType() { + return MqttsnConstants.ADVERTISE; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + gatewayId = read8BitAdjusted(data, 2); + duration = read16BitAdjusted(data, 3); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] data = new byte[5]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + data[2] = (byte) gatewayId; + data[3] = (byte) ((duration >> 8) & 0xFF); + data[4] = (byte) (duration & 0xFF); + return data; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnAdvertise{"); + sb.append("gatewayId=").append(gatewayId); + sb.append(", duration=").append(duration); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnConnack.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnConnack.java new file mode 100644 index 00000000..6adbc275 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnConnack.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnConnack extends AbstractMqttsnMessage { + + @Override + public int getMessageType() { + return MqttsnConstants.CONNACK; + } + + @Override + public void decode(byte[] arr) throws MqttsnCodecException { + setReturnCode(arr[arr.length - 1]); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] msg = new byte[3]; + msg[0] = 3; + msg[1] = (byte) getMessageType(); + msg[2] = (byte) getReturnCode(); + return msg; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnConnack{"); + sb.append("messageType=").append(messageType); + sb.append(", returnCode=").append(returnCode); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnConnect.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnConnect.java new file mode 100644 index 00000000..b2587db1 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnConnect.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.spi.IMqttsnIdentificationPacket; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.Mqttsn_v1_2_Codec; + +/** + * NB: despite the spec only allowing 23 chars in the clientId field, this type has been designed safely to support + * clientIds which take the message into an extended type (> 255). + */ +public class MqttsnConnect extends AbstractMqttsnMessageWithFlagsField implements IMqttsnIdentificationPacket { + + /* The Duration field is 2-octet long and specifies the duration of a time period in seconds. + The maximum value that can be encoded is approximately 18 hours. */ + protected int duration; + + protected int protocolId; + + /* 1-23 characters long string that uniquely identifies the client to the server */ + protected String clientId = null; + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public int getProtocolId() { + return protocolId; + } + + public void setProtocolId(int protocolId) { + this.protocolId = protocolId; + } + + @Override + public int getMessageType() { + return MqttsnConstants.CONNECT; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + + if (Mqttsn_v1_2_Codec.isExtendedMessage(data)) { + readFlags(data[4]); + } else { + readFlags(data[2]); + } + + protocolId = read8BitAdjusted(data, 3); + duration = read16BitAdjusted(data, 4); + + byte[] body = readRemainingBytesFromIndexAdjusted(data, 6); + if (body.length > 0) { + clientId = new String(body, MqttsnConstants.CHARSET); + } + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + int length = 6 + (clientId == null ? 0 : clientId.length()); + byte[] msg = null; + int idx = 0; + if ((length) > 0xFF) { + length += 2; + msg = new byte[length]; + msg[idx++] = (byte) 0x01; + msg[idx++] = ((byte) (0xFF & (length >> 8))); + msg[idx++] = ((byte) (0xFF & length)); + } else { + msg = new byte[length]; + msg[idx++] = (byte) length; + } + + msg[idx++] = (byte) getMessageType(); + msg[idx++] = writeFlags(); + msg[idx++] = (byte) protocolId; //protocol id + + msg[idx++] = (byte) ((duration >> 8) & 0xFF); + msg[idx++] = (byte) (duration & 0xFF); + + if (clientId != null) { + byte[] clientIdArr = clientId.getBytes(MqttsnConstants.CHARSET); + System.arraycopy(clientIdArr, 0, msg, idx, clientIdArr.length); + } + + return msg; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnConnect{"); + sb.append("duration=").append(duration); + sb.append(", protocolId=").append(protocolId); + sb.append(", clientId='").append(clientId).append('\''); + sb.append(", will=").append(will); + sb.append(", cleanSession=").append(cleanSession); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnDisconnect.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnDisconnect.java new file mode 100644 index 00000000..3fb06a0d --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnDisconnect.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnDisconnect extends AbstractMqttsnMessage { + + protected int duration; + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + @Override + public int getMessageType() { + return MqttsnConstants.DISCONNECT; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + if (data.length == 4) { + duration = read16BitAdjusted(data, 2); + } + } + + @Override + public byte[] encode() throws MqttsnCodecException { + byte[] data = new byte[duration > 0 ? 4 : 2]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + if (data.length == 4) { + data[2] = (byte) ((duration >> 8) & 0xFF); + data[3] = (byte) (duration & 0xFF); + } + return data; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnDisconnect{"); + sb.append("duration=").append(duration); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnEncapsmsg.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnEncapsmsg.java new file mode 100644 index 00000000..202581e3 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnEncapsmsg.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.Mqttsn_v1_2_Codec; + +import java.util.Arrays; + +public class MqttsnEncapsmsg extends AbstractMqttsnMessage { + + protected byte[] encapsulatedMsg; + protected byte[] wirelessNodeId; + int radius; + + public byte[] getEncapsulatedMsg() { + return encapsulatedMsg; + } + + public void setEncapsulatedMsg(byte[] encapsulatedMsg) { + this.encapsulatedMsg = encapsulatedMsg; + } + + public String getWirelessNodeId() { + return wirelessNodeId != null && wirelessNodeId.length > 0 ? + new String(wirelessNodeId, MqttsnConstants.CHARSET) : null; + } + + public void setWirelessNodeId(String wirelessNodeIdStr) { + this.wirelessNodeId = + wirelessNodeIdStr == null ? null : wirelessNodeIdStr.getBytes(MqttsnConstants.CHARSET); + } + + public int getRadius() { + return radius; + } + + public void setRadius(int radius) { + this.radius = radius; + } + + @Override + public int getMessageType() { + return MqttsnConstants.ENCAPSMSG; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + int length = Mqttsn_v1_2_Codec.readMessageLength(data); + + int offset = 0; + if (Mqttsn_v1_2_Codec.isExtendedMessage(data)) { + offset = 2; + } + int idx = 2 + offset; + byte ctrl = data[idx]; + radius = ctrl & 0x03; + + //idx = 4 | 2 + wirelessNodeId = new byte[length - idx]; + if (wirelessNodeId.length > 0) { + System.arraycopy(data, idx, wirelessNodeId, 0, wirelessNodeId.length); + idx += wirelessNodeId.length; + } + + //-- the rest is the next message + //idx = 4 | 2 + encapsulatedMsg = new byte[data.length - length]; + if (encapsulatedMsg.length > 0) { + System.arraycopy(data, idx, encapsulatedMsg, 0, encapsulatedMsg.length); + } + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + + //Length: 1-octet long, specifies the number of octets up to the end of the "Wireless Node Id" field (incl. the Length octet itself) + int length = 3 + + (wirelessNodeId != null ? wirelessNodeId.length : 0); + byte[] msg; + int idx = 0; + + if ((length) > 0xFF) { + length += 2; + msg = new byte[length]; + msg[idx++] = (byte) 0x01; + msg[idx++] = ((byte) (0xFF & (length >> 8))); + msg[idx++] = ((byte) (0xFF & length)); + } else { + msg = new byte[length]; + msg[idx++] = (byte) length; + } + + msg[idx++] = (byte) getMessageType(); + + //CTRL +// reserved (bit 7:2) + byte ctrl = 0; + ctrl |= radius; //(bit 1,0) + msg[idx++] = ctrl; + + byte[] nodeId; + if (wirelessNodeId != null && wirelessNodeId.length > 0) { + System.arraycopy(wirelessNodeId, 0, msg, idx, wirelessNodeId.length); + idx += wirelessNodeId.length; + } + + if (encapsulatedMsg != null && encapsulatedMsg.length > 0) { + System.arraycopy(encapsulatedMsg, 0, msg, idx, encapsulatedMsg.length); + } + + return msg; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnEncapsmsg{"); + sb.append("wirelessNodeId=").append(Arrays.toString(wirelessNodeId)); + sb.append(", radius=").append(radius); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnGwInfo.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnGwInfo.java new file mode 100644 index 00000000..d3825bc8 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnGwInfo.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnGwInfo extends AbstractMqttsnMessage { + + protected int gatewayId; + protected String gatewayAddress; + + public int getGatewayId() { + return gatewayId; + } + + public void setGatewayId(int gatewayId) { + this.gatewayId = gatewayId; + } + + public String getGatewayAddress() { + return gatewayAddress; + } + + public void setGatewayAddress(String gatewayAddress) { + this.gatewayAddress = gatewayAddress; + } + + @Override + public int getMessageType() { + return MqttsnConstants.GWINFO; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + + gatewayId = (data[2] & 0xFF); + byte[] body = new byte[data[0] - 3]; + if (body.length > 0) { + System.arraycopy(data, 3, body, 0, body.length); + gatewayAddress = new String(body, MqttsnConstants.CHARSET); + } + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + int length = 3 + (gatewayAddress == null ? 0 : gatewayAddress.length()); + byte[] data = new byte[length]; + data[0] = (byte) length; + data[1] = (byte) getMessageType(); + data[2] = (byte) gatewayId; + if (gatewayAddress != null) { + System.arraycopy(gatewayAddress.getBytes(MqttsnConstants.CHARSET), 0, data, 3, gatewayAddress.length()); + } + return data; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnGwInfo{"); + sb.append("gatewayId=").append(gatewayId); + sb.append(", gatewayAddress='").append(gatewayAddress).append('\''); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPingreq.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPingreq.java new file mode 100644 index 00000000..f34ab260 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPingreq.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.spi.IMqttsnIdentificationPacket; + +public class MqttsnPingreq extends AbstractMqttsnMessage implements IMqttsnIdentificationPacket { + + protected String clientId; + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + @Override + public int getMessageType() { + return MqttsnConstants.PINGREQ; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + byte[] body = readRemainingBytesFromIndexAdjusted(data, 2); + if (body.length > 0) { + clientId = new String(body, MqttsnConstants.CHARSET); + } + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + int length = 2 + (clientId == null ? 0 : clientId.length()); + byte[] msg = null; + int idx = 0; + if (length > 0xFF) { + length += 2; + msg = new byte[length]; + msg[idx++] = (byte) 0x01; + msg[idx++] = ((byte) (0xFF & (length >> 8))); + msg[idx++] = ((byte) (0xFF & length)); + } else { + msg = new byte[length]; + msg[idx++] = (byte) length; + } + + msg[idx++] = (byte) getMessageType(); + + if (clientId != null) { + System.arraycopy(clientId.getBytes(MqttsnConstants.CHARSET), 0, msg, idx, clientId.length()); + } + return msg; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnPingreq{"); + sb.append("clientId='").append(clientId).append('\''); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPingresp.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPingresp.java new file mode 100644 index 00000000..eefc28cd --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPingresp.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnPingresp extends AbstractMqttsnMessage { + + @Override + public int getMessageType() { + return MqttsnConstants.PINGRESP; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] data = new byte[2]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + return data; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnPingresp{"); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPuback.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPuback.java new file mode 100644 index 00000000..d2b12f6f --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPuback.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnPuback extends AbstractMqttsnMessage { + + protected int topicId; + + public boolean needsMsgId() { + return true; + } + + public int getTopicId() { + return topicId; + } + + public void setTopicId(int topicId) { + this.topicId = topicId; + } + + @Override + public int getMessageType() { + return MqttsnConstants.PUBACK; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + + topicId = read16BitAdjusted(data, 2); + msgId = read16BitAdjusted(data, 4); + returnCode = (data[6] & 0xFF); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] data = new byte[7]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + + data[2] = (byte) ((topicId >> 8) & 0xFF); + data[3] = (byte) (topicId & 0xFF); + + data[4] = (byte) ((msgId >> 8) & 0xFF); + data[5] = (byte) (msgId & 0xFF); + + data[6] = (byte) returnCode; + + return data; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnPuback{"); + sb.append("topicId=").append(topicId); + sb.append(", msgId=").append(msgId); + sb.append(", returnCode=").append(returnCode); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubcomp.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubcomp.java new file mode 100644 index 00000000..54dd5e22 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubcomp.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class MqttsnPubcomp extends AbstractMqttsnPublishMessageConfirmation { + + @Override + public int getMessageType() { + return MqttsnConstants.PUBCOMP; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnPubcomp{"); + sb.append("msgId=").append(msgId); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPublish.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPublish.java new file mode 100644 index 00000000..ea150d32 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPublish.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.Mqttsn_v1_2_Codec; + +import java.util.Arrays; + +public class MqttsnPublish extends AbstractMqttsnMessageWithTopicData { + + public boolean needsMsgId() { + return true; + } + + protected byte[] data; + + public byte[] getData() { + return data; + } + + public void setData(byte[] data) { + this.data = data; + } + + @Override + public int getMessageType() { + return MqttsnConstants.PUBLISH; + } + + @Override + public void decode(byte[] arr) throws MqttsnCodecException { + readFlags(Mqttsn_v1_2_Codec.readHeaderByteWithOffset(arr, 2)); + setTopicData(readBytesFromIndexAdjusted(arr, 3, 2)); + msgId = read16BitAdjusted(arr, 5); + data = readRemainingBytesFromIndexAdjusted(arr, 7); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] msg; + int length = data.length + 7; + int idx = 0; + + if ((length) > 0xFF) { + length += 2; + msg = new byte[length]; + msg[idx++] = (byte) 0x01; + msg[idx++] = ((byte) (0xFF & (length >> 8))); + msg[idx++] = ((byte) (0xFF & length)); + } else { + msg = new byte[length]; + msg[idx++] = (byte) length; + } + + msg[idx++] = (byte) getMessageType(); + msg[idx++] = writeFlags(); + + //-- copy in the topic data + System.arraycopy(topicData, 0, msg, idx, topicData.length); + idx += topicData.length; + + msg[idx++] = (byte) ((msgId >> 8) & 0xFF); + msg[idx++] = (byte) (msgId & 0xFF); + + System.arraycopy(data, 0, msg, msg.length - (data.length), data.length); + return msg; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnPublish{"); + sb.append("topicData=").append(Arrays.toString(topicData)); + sb.append(", dup=").append(dupRedelivery); + sb.append(", QoS=").append(QoS); + sb.append(", retain=").append(retainedPublish); + sb.append(", topicIdType=").append(topicType); + sb.append(", msgId=").append(msgId); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubrec.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubrec.java new file mode 100644 index 00000000..ccd6222f --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubrec.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class MqttsnPubrec extends AbstractMqttsnPublishMessageConfirmation { + + @Override + public int getMessageType() { + return MqttsnConstants.PUBREC; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnPubrec{"); + sb.append("msgId=").append(msgId); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubrel.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubrel.java new file mode 100644 index 00000000..5d8879ad --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnPubrel.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class MqttsnPubrel extends AbstractMqttsnPublishMessageConfirmation { + + @Override + public int getMessageType() { + return MqttsnConstants.PUBREL; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnPubrel{"); + sb.append("msgId=").append(msgId); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnRegack.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnRegack.java new file mode 100644 index 00000000..3bd519a9 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnRegack.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnRegack extends AbstractMqttsnMessage { + + protected int topicId; + + public boolean needsMsgId() { + return true; + } + + public int getTopicId() { + return topicId; + } + + public void setTopicId(int topicId) { + this.topicId = topicId; + } + + @Override + public int getMessageType() { + return MqttsnConstants.REGACK; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + + topicId = read16BitAdjusted(data, 2); + msgId = read16BitAdjusted(data, 4); + returnCode = (data[6] & 0xFF); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] data = new byte[7]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + + data[2] = (byte) ((topicId >> 8) & 0xFF); + data[3] = (byte) (topicId & 0xFF); + + data[4] = (byte) ((msgId >> 8) & 0xFF); + data[5] = (byte) (msgId & 0xFF); + + data[6] = (byte) returnCode; + + return data; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnRegack{"); + sb.append("topicId=").append(topicId); + sb.append(", msgId=").append(msgId); + sb.append(", returnCode=").append(returnCode); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnRegister.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnRegister.java new file mode 100644 index 00000000..9bb70674 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnRegister.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.Mqttsn_v1_2_Codec; + +public class MqttsnRegister extends AbstractMqttsnMessage { + + protected int topicId; + protected String topicName; + + public boolean needsMsgId() { + return true; + } + + public int getTopicId() { + return topicId; + } + + public void setTopicId(int topicId) { + this.topicId = topicId; + } + + public String getTopicName() { + return topicName; + } + + public void setTopicName(String topicName) { + this.topicName = topicName; + } + + @Override + public int getMessageType() { + return MqttsnConstants.REGISTER; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + + if (Mqttsn_v1_2_Codec.isExtendedMessage(data)) { + topicId = read16BitAdjusted(data, 4); + msgId = read16BitAdjusted(data, 6); + } else { + topicId = read16BitAdjusted(data, 2); + msgId = read16BitAdjusted(data, 4); + } + + byte[] body = readRemainingBytesFromIndexAdjusted(data, 6); + if (body.length > 0) { + topicName = new String(body, MqttsnConstants.CHARSET); + } + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] topicByteArr = null; + int length = 6 + (topicName == null ? 0 : ((topicByteArr = topicName.getBytes(MqttsnConstants.CHARSET)).length)); + byte[] msg = null; + int idx = 0; + if ((length) > 0xFF) { + + length += 2; + + msg = new byte[length]; + msg[idx++] = (byte) 0x01; + msg[idx++] = ((byte) (0xFF & (length >> 8))); + msg[idx++] = ((byte) (0xFF & length)); + + } else { + + msg = new byte[length]; + msg[idx++] = (byte) length; + } + + msg[idx++] = (byte) getMessageType(); + + msg[idx++] = ((byte) (0xFF & (topicId >> 8))); + msg[idx++] = ((byte) (0xFF & topicId)); + + msg[idx++] = ((byte) (0xFF & (msgId >> 8))); + msg[idx++] = ((byte) (0xFF & msgId)); + + if (topicByteArr != null && topicByteArr.length > 0) { + System.arraycopy(topicByteArr, 0, msg, idx, topicByteArr.length); + } + + return msg; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnRegister{"); + sb.append("topicId=").append(topicId); + sb.append(", topicName='").append(topicName).append('\''); + sb.append(", msgId=").append(msgId); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSearchGw.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSearchGw.java new file mode 100644 index 00000000..62ef1420 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSearchGw.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnSearchGw extends AbstractMqttsnMessage { + + protected int radius; + + public int getRadius() { + return radius; + } + + public void setRadius(int radius) { + this.radius = radius; + } + + @Override + public int getMessageType() { + return MqttsnConstants.SEARCHGW; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + radius = (data[2] & 0xFF); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + int length = 3; + byte[] data = new byte[length]; + data[0] = (byte) length; + data[1] = (byte) getMessageType(); + data[2] = (byte) radius; + return data; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnSearchGw{"); + sb.append("radius=").append(radius); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSuback.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSuback.java new file mode 100644 index 00000000..44a6e91f --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSuback.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnSuback extends AbstractMqttsnMessageWithFlagsField { + + protected int topicId; + + public boolean needsMsgId() { + return true; + } + + public int getTopicId() { + return topicId; + } + + public void setTopicId(int topicId) { + this.topicId = topicId; + } + + @Override + public int getMessageType() { + return MqttsnConstants.SUBACK; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + + readFlags(data[2]); + topicId = read16BitAdjusted(data, 3); + msgId = read16BitAdjusted(data, 5); + returnCode = read8BitAdjusted(data, 7); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] data = new byte[8]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + + data[2] = writeFlags(); + + data[3] = (byte) ((topicId >> 8) & 0xFF); + data[4] = (byte) (topicId & 0xFF); + + data[5] = (byte) ((msgId >> 8) & 0xFF); + data[6] = (byte) (msgId & 0xFF); + + data[7] = (byte) returnCode; + + return data; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnSuback{"); + sb.append("topicId=").append(topicId); + sb.append(", grantedQoS=").append(getQoS()); + sb.append(", msgId=").append(msgId); + sb.append(", returnCode=").append(returnCode); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSubscribe.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSubscribe.java new file mode 100644 index 00000000..bdcb1b7d --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnSubscribe.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +import java.util.Arrays; + +public class MqttsnSubscribe extends AbstractMqttsnSubscribeUnsubscribe { + + @Override + public int getMessageType() { + return MqttsnConstants.SUBSCRIBE; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnSubscribe{"); + sb.append("topicData=").append(Arrays.toString(topicData)); + sb.append(", QoS=").append(QoS); + sb.append(", topicIdType=").append(topicType); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnUnsuback.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnUnsuback.java new file mode 100644 index 00000000..20e492d5 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnUnsuback.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; + +public class MqttsnUnsuback extends AbstractMqttsnMessage { + + public boolean needsMsgId() { + return true; + } + + @Override + public int getMessageType() { + return MqttsnConstants.UNSUBACK; + } + + @Override + public void decode(byte[] data) throws MqttsnCodecException { + msgId = read16BitAdjusted(data, 2); + } + + @Override + public byte[] encode() throws MqttsnCodecException { + + byte[] data = new byte[4]; + data[0] = (byte) data.length; + data[1] = (byte) getMessageType(); + + data[2] = (byte) ((msgId >> 8) & 0xFF); + data[3] = (byte) (msgId & 0xFF); + + return data; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnUnsuback{"); + sb.append("msgId=").append(msgId); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnUnsubscribe.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnUnsubscribe.java new file mode 100644 index 00000000..137f324c --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnUnsubscribe.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +import java.util.Arrays; + +public class MqttsnUnsubscribe extends AbstractMqttsnSubscribeUnsubscribe { + @Override + public int getMessageType() { + return MqttsnConstants.UNSUBSCRIBE; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnUnsubscribe{"); + sb.append("topicData=").append(Arrays.toString(topicData)); + sb.append(", topicIdType=").append(topicType); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsg.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsg.java new file mode 100644 index 00000000..fd07785d --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsg.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class MqttsnWillmsg extends AbstractMqttsnWillMessage { + + @Override + public int getMessageType() { + return MqttsnConstants.WILLMSG; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnWillmsg{"); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgreq.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgreq.java new file mode 100644 index 00000000..675e2b71 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgreq.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class MqttsnWillmsgreq extends AbstractMqttsnSimpleMessage { + + @Override + public int getMessageType() { + return MqttsnConstants.WILLMSGREQ; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnWillmsgreq{"); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgresp.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgresp.java new file mode 100644 index 00000000..ead36f25 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgresp.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class MqttsnWillmsgresp extends AbstractMqttsnWillresp { + + @Override + public int getMessageType() { + return MqttsnConstants.WILLMSGRESP; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnWillmsgresp{"); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgupd.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgupd.java new file mode 100644 index 00000000..5d5ff979 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWillmsgupd.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class MqttsnWillmsgupd extends AbstractMqttsnWillMessage { + + @Override + public int getMessageType() { + return MqttsnConstants.WILLMSGUPD; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnWillmsgupd{"); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopic.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopic.java new file mode 100644 index 00000000..255616c3 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopic.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +import java.util.Arrays; + +public class MqttsnWilltopic extends AbstractMqttsnWillTopicMessage { + + @Override + public int getMessageType() { + return MqttsnConstants.WILLTOPIC; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnWilltopic{"); + sb.append("willTopicData=").append(Arrays.toString(willTopicData)); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicreq.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicreq.java new file mode 100644 index 00000000..79b54d8d --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicreq.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class MqttsnWilltopicreq extends AbstractMqttsnSimpleMessage { + + @Override + public int getMessageType() { + return MqttsnConstants.WILLTOPICREQ; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnWilltopicreq{"); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicresp.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicresp.java new file mode 100644 index 00000000..46fe7d76 --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicresp.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class MqttsnWilltopicresp extends AbstractMqttsnWillresp { + + @Override + public int getMessageType() { + return MqttsnConstants.WILLTOPICRESP; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnWilltopicresp{"); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicudp.java b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicudp.java new file mode 100644 index 00000000..01cbf3dd --- /dev/null +++ b/mqtt-sn-codec/src/main/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWilltopicudp.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.slj.mqtt.sn.MqttsnConstants; + +import java.util.Arrays; + +public class MqttsnWilltopicudp extends AbstractMqttsnWillTopicMessage { + + @Override + public int getMessageType() { + return MqttsnConstants.WILLTOPICUPD; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("MqttsnWilltopicudp{"); + sb.append("willTopicData=").append(Arrays.toString(willTopicData)); + sb.append(", topicIdType=").append(topicType); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-codec/src/test/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWireTests.java b/mqtt-sn-codec/src/test/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWireTests.java new file mode 100644 index 00000000..d3be8093 --- /dev/null +++ b/mqtt-sn-codec/src/test/java/org/slj/mqtt/sn/wire/version1_2/payload/MqttsnWireTests.java @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.wire.version1_2.payload; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.codec.MqttsnCodecs; +import org.slj.mqtt.sn.spi.IMqttsnCodec; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.IMqttsnMessageFactory; + +import java.util.Arrays; + +import static org.junit.Assert.*; + +public class MqttsnWireTests { + + static final byte _payload = 0x10; + static final String _path = "/topic/path"; + static final String _clientid = "client-id"; + static final int _radius = 2; + static final int _alias = 12; + static final int _qos = 2; + static final int _msgId = 254; +// static final int _msgId = MqttsnConstants.USIGNED_MAX_16; + + IMqttsnCodec codec; + IMqttsnMessageFactory factory; + + @Before + public void setup(){ + codec = MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2; + factory = codec.createMessageFactory(); + } + + @Test + public void testMqttsnAdvertise() throws MqttsnCodecException { + IMqttsnMessage message = factory.createAdvertise(MqttsnConstants.USIGNED_MAX_8, + MqttsnConstants.USIGNED_MAX_16); + testWireMessage(message); + } + + @Test + public void testMqttsnConnack() throws MqttsnCodecException { + IMqttsnMessage message = factory.createConnack(MqttsnConstants.RETURN_CODE_ACCEPTED); + testWireMessage(message); + } + + @Test + public void testMqttsnConnect() throws MqttsnCodecException { + + //-- test normal length clientId + IMqttsnMessage message = factory.createConnect(_clientid, MqttsnConstants.USIGNED_MAX_16, false, true); + testWireMessage(message); + } + + @Test + public void testMqttsnConnectLongClientId() throws MqttsnCodecException { + + //-- test very long clientId + StringBuilder sb = new StringBuilder(1024); + for (int i = 0; i < 1024; i++){ + sb.append("A"); + } + + IMqttsnMessage message = factory.createConnect(sb.toString(), MqttsnConstants.USIGNED_MAX_16, false, true); + testWireMessage(message); + } + + @Test + public void testMqttsnDisconnect() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createDisconnect(); + testWireMessage(message); + } + + @Test + public void testMqttsnDisconnectWithDuration() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createDisconnect(MqttsnConstants.USIGNED_MAX_16); + testWireMessage(message); + } + + @Test + public void testMqttsnGwinfo() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createGwinfo(MqttsnConstants.USIGNED_MAX_8, "123:123123:0:c:12:2"); + testWireMessage(message); + } + + @Test + public void testMqttsnPingreq() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPingreq(_clientid); + testWireMessage(message); + } + + @Test + public void testMqttsnPingresp() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPingresp(); + testWireMessage(message); + } + + @Test + public void testMqttsnPubackError() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPuback(_alias, MqttsnConstants.RETURN_CODE_INVALID_TOPIC_ID); + testWireMessage(message); + } + + @Test + public void testMqttsnPublishNormalTopic() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPublish(_qos, true, false, MqttsnConstants.TOPIC_TYPE.NORMAL, _alias, payload(4)); + message.setMsgId(25); + testWireMessage(message); + } + + @Test + public void testMqttsnPublishPredefinedTopic() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPublish(_qos, true, false, MqttsnConstants.TOPIC_TYPE.PREDEFINED, _alias, payload(4)); + testWireMessage(message); + } + + @Test + public void testMqttsnPublishShortTopic() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPublish(_qos, true, false, "ab", payload(4)); + testWireMessage(message); + } + + @Test + public void testMqttsnPublishLong() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPublish(_qos, true, false, MqttsnConstants.TOPIC_TYPE.PREDEFINED, _alias, payload(MqttsnConstants.USIGNED_MAX_16)); + testWireMessage(message); + } + + @Test + public void testMqttsnPubrel() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPubrel(); + testWireMessage(message); + } + + @Test + public void testMqttsnPubrec() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPubrec(); + testWireMessage(message); + } + + @Test + public void testMqttsnPuback() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPuback(_alias, MqttsnConstants.RETURN_CODE_ACCEPTED); + testWireMessage(message); + } + + @Test + public void testMqttsnPubcomp() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createPubcomp(); + testWireMessage(message); + } + + @Test + public void testMqttsnRegack() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createRegack(_alias, MqttsnConstants.RETURN_CODE_ACCEPTED); + testWireMessage(message); + } + + @Test + public void testMqttsnRegackError() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createRegack(_alias, MqttsnConstants.RETURN_CODE_INVALID_TOPIC_ID); + testWireMessage(message); + } + + @Test + public void testMqttsnRegisterPath() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createRegister(_path); + testWireMessage(message); + } + + @Test + public void testMqttsnRegisterPathWithAlias() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createRegister(_alias, _path); + testWireMessage(message); + } + + @Test + public void testMqttsnSearchGw() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createSearchGw(_radius); + testWireMessage(message); + } + + @Test + public void testMqttsnSuback() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createSuback(_qos, _alias, MqttsnConstants.RETURN_CODE_ACCEPTED); + testWireMessage(message); + } + + @Test + public void testMqttsnSubscribePath() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createSubscribe(_qos, _path); + testWireMessage(message); + } + + @Test + public void testMqttsnSubscribePredefined() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createSubscribe(_qos, MqttsnConstants.TOPIC_TYPE.PREDEFINED, _alias); + testWireMessage(message); + } + + @Test + public void testMqttsnUnsubscribe() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createUnsubscribe(_path); + testWireMessage(message); + } + + @Test + public void testMqttsnUnsubscribePredefined() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createUnsubscribe(MqttsnConstants.TOPIC_TYPE.PREDEFINED, _alias); + testWireMessage(message); + } + + @Test + public void testMqttsnWillmsg() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillMsg(payload(50)); + testWireMessage(message); + } + + @Test + public void testMqttsnWillmsgreq() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillMsgReq(); + testWireMessage(message); + } + + @Test + public void testMqttsnWillmsgresp() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillMsgResp(MqttsnConstants.RETURN_CODE_ACCEPTED); + testWireMessage(message); + } + + @Test + public void testMqttsnWillmsgrespError() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillMsgResp(MqttsnConstants.RETURN_CODE_REJECTED_CONGESTION); + testWireMessage(message); + } + + @Test + public void testMqttsnWillmsgupd() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillMsgupd(payload(50)); + testWireMessage(message); + } + + @Test + public void testMqttsnWilltopic() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillTopic(_qos, true, _path); + testWireMessage(message); + } + + @Test + public void testMqttsnWilltopicreq() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillTopicReq(); + testWireMessage(message); + } + + @Test + public void testMqttsnWilltopicresp() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillTopicResp(MqttsnConstants.RETURN_CODE_ACCEPTED); + testWireMessage(message); + } + + @Test + public void testMqttsnWilltopicrespError() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillTopicResp(MqttsnConstants.RETURN_CODE_SERVER_UNAVAILABLE); + testWireMessage(message); + } + + @Test + public void testMqttsnWilltopicupd() throws MqttsnCodecException { + + IMqttsnMessage message = factory.createWillTopicupd(_qos, true, _path); + testWireMessage(message); + } + + protected void testWireMessage(IMqttsnMessage message) throws MqttsnCodecException { + + if(message.needsMsgId()){ + message.setMsgId(_msgId); + } + + String toString = message.toString(); + byte[] arr = codec.encode(message); + + System.out.println(String.format("before [%s] -> [%s]", toString, codec.print(message))); + + IMqttsnMessage decoded = codec.decode(arr); + String afterToString = decoded.toString(); + + System.out.println(String.format("after [%s] -> [%s]", afterToString, codec.print(message))); + + //-- first ensure the toStrings match since they contain the important data fields for each type + Assert.assertEquals("message content should match", toString, afterToString); + + //-- re-encode to ensure a full pass of all fields + byte[] reencoded = codec.encode(decoded); + Assert.assertArrayEquals("binary content should match", arr, reencoded); + } + + static byte[] payload(int size){ + + byte[] arr = new byte[size]; + Arrays.fill(arr, _payload); + return arr; + } +} \ No newline at end of file diff --git a/mqtt-sn-core/ext/create-keystore.sh b/mqtt-sn-core/ext/create-keystore.sh new file mode 100644 index 00000000..d13a118d --- /dev/null +++ b/mqtt-sn-core/ext/create-keystore.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +# +# /* +# * Copyright (c) 2021 Simon Johnson +# * +# * Find me on GitHub: +# * https://github.com/simon622 +# * +# * Licensed to the Apache Software Foundation (ASF) under one +# * or more contributor license agreements. See the NOTICE file +# * distributed with this work for additional information +# * regarding copyright ownership. The ASF licenses this file +# * to you under the Apache License, Version 2.0 (the +# * "License"); you may not use this file except in compliance +# * with the License. You may obtain a copy of the License at +# * +# * http://www.apache.org/licenses/LICENSE-2.0 +# * +# * Unless required by applicable law or agreed to in writing, +# * software distributed under the License is distributed on an +# * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# * KIND, either express or implied. See the License for the +# * specific language governing permissions and limitations +# * under the License. +# */ +# +# + +rm my-keystore.jks 2> /dev/null +rm my-truststore.jks 2> /dev/null + +echo "=================================================" +echo "Generating keystore..." +echo "=================================================" + +keytool -genkeypair -alias a1 -dname cn=a1 \ + -validity 10000 -keyalg RSA -keysize 2048 \ + -keystore my-keystore.jks -keypass password -storepass password + +keytool -list -v -storepass password -keystore my-keystore.jks + +echo "=================================================" +echo "Generating truststore..." +echo "=================================================" + + keytool -exportcert -alias a1 \ + -keystore my-keystore.jks -keypass password -storepass password \ +| keytool -importcert -noprompt -alias a1 \ + -keystore my-truststore.jks -keypass password -storepass password + +keytool -list -v -storepass password -keystore my-truststore.jks \ No newline at end of file diff --git a/mqtt-sn-core/pom.xml b/mqtt-sn-core/pom.xml new file mode 100644 index 00000000..f0cf05bf --- /dev/null +++ b/mqtt-sn-core/pom.xml @@ -0,0 +1,66 @@ + + + + + 4.0.0 + + org.slj + mqtt-sn-core + 1.0.0 + + + UTF-8 + 4.13 + 3.8.1 + 1.8 + 1.8 + + + + + org.slj + mqtt-sn-codec + 1.0.0 + + + junit + junit + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + \ No newline at end of file diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/cli/AbstractInteractiveCli.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/cli/AbstractInteractiveCli.java new file mode 100644 index 00000000..cfda5676 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/cli/AbstractInteractiveCli.java @@ -0,0 +1,402 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.cli; + +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntime; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.IMqttsnTrafficListener; +import org.slj.mqtt.sn.spi.IMqttsnTransport; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.io.*; +import java.net.UnknownHostException; +import java.util.Date; +import java.util.Properties; +import java.util.Scanner; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; + +public abstract class AbstractInteractiveCli { + + static final String HOSTNAME = "hostName"; + static final String CLIENTID = "clientId"; + static final String PORT = "port"; + + protected final AtomicInteger sentByteCount = new AtomicInteger(0); + protected final AtomicInteger receiveByteCount = new AtomicInteger(0); + protected final AtomicInteger receiveCount = new AtomicInteger(0); + protected final AtomicInteger receivedPublishBytesCount = new AtomicInteger(0); + protected final AtomicInteger sentCount = new AtomicInteger(0); + protected final AtomicInteger publishedBytesCount = new AtomicInteger(0); + + protected boolean colors = false; + protected boolean useHistory = false; + protected String hostName; + protected int port; + protected String clientId; + + protected MqttsnOptions options; + protected AbstractMqttsnRuntimeRegistry runtimeRegistry; + protected AbstractMqttsnRuntime runtime; + + protected PrintStream output; + protected Scanner input; + protected volatile boolean closed = false; + + public void init(Scanner input, PrintStream output){ + this.input = input; + this.output = output; + colors = !System.getProperty("os.name").contains("Windows"); + } + + public void start() throws Exception { + if(input == null || output == null) throw new IllegalStateException("no init"); + message(String.format("Starting up interactive CLI with port=%s, clientId=%s", port, clientId)); + if(useHistory){ + saveConfig(); + } + + message(String.format("Creating runtime configuration.. DONE")); + options = createOptions(); + message(String.format("Creating runtime registry.. DONE")); + runtimeRegistry = createRuntimeRegistry(options, createTransport()); + message(String.format("Creating runtime .. DONE")); + runtime = createRuntime(runtimeRegistry, options); + + message("Adding runtime listeners.. DONE"); + runtime.registerReceivedListener((IMqttsnContext context, String topic, int qos, byte[] data) -> { + try { + receiveCount.incrementAndGet(); + receivedPublishBytesCount.addAndGet(data.length); + output.println(); + message(String.format(cli_blue() + "[<<<] PUBLISH [%s] bytes on [%s] from [%s] -> [%s]", data.length, topic, context.getId(), new String(data))); + } catch(Exception e){ + e.printStackTrace(); + } + }); + runtime.registerSentListener((IMqttsnContext context, UUID messageId, String topicName, int QoS, byte[] data) -> { + try { + sentCount.incrementAndGet(); + publishedBytesCount.addAndGet(data.length); + message(String.format(cli_blue() + "[>>>] PUBLISH [%s] bytes on [%s] to [%s] -> [%s]", data.length, topicName, context.getId(), new String(data))); + } catch(Exception e){ + e.printStackTrace(); + } + }); + + runtime.registerPublishFailedListener((IMqttsnContext context, UUID messageId, String topicName, int QoS, byte[] data, int retryCount) -> { + try { + sentCount.incrementAndGet(); + message(String.format(cli_blue() + "[XXX] PUBLISH failure (tried [%s] times) [%s] bytes on topic [%s], at QoS [%s] -> [%s]", retryCount, data.length, topicName, QoS, new String(data))); + } catch(Exception e){ + e.printStackTrace(); + } + }); + + message("Adding traffic listeners.. DONE"); + runtimeRegistry.withTrafficListener(new IMqttsnTrafficListener() { + @Override + public void trafficSent(INetworkContext context, byte[] data, IMqttsnMessage message) { + sentByteCount.addAndGet(data.length); + } + + @Override + public void trafficReceived(INetworkContext context, byte[] data, IMqttsnMessage message) { + receiveByteCount.addAndGet(data.length); + } + }); + + message("Runtime successfully initialised."); + } + + public void welcome(){ + output.println("==============================================="); + output.println("Welcome to " + getCLIName()); + output.println("==============================================="); + } + + public void exit(){ + output.println("==============================================="); + output.println("Goodbye from " + getCLIName()); + output.println("==============================================="); + } + + protected void configure() throws IOException { + + do{ + hostName = captureMandatoryString(input, output, "Please enter a valid host name or ip address"); + } while(!validHost()); + + port = captureMandatoryInt(input, output, "Please enter a port", null); + do{ + clientId = captureMandatoryString(input, output, "Please enter a valid clientId"); + } while(!validClientId()); + } + + public void configureWithHistory() throws IOException { + boolean useConfig = false; + useHistory = true; + if(loadConfig()){ + if(configOk()){ + useConfig = captureMandatoryBoolean(input, output, + String.format("I managed to load port=%s, clientId=%s from history, would you like to use it?", + port, clientId)); + } + } + + if(!useConfig){ + configure(); + } + } + + protected boolean configOk(){ + return hostName != null && port != 0 && clientId != null; + } + + protected void loadConfigHistory(Properties props) throws IOException { + hostName = props.getProperty(HOSTNAME); + port = Integer.valueOf(props.getProperty(PORT)); + clientId = props.getProperty(CLIENTID); + } + + protected void saveConfigHistory(Properties props) { + props.setProperty(HOSTNAME, hostName); + props.setProperty(PORT, String.valueOf(port)); + props.setProperty(CLIENTID, clientId); + } + + protected boolean loadConfig() throws IOException { + Properties properties = new Properties(); + File f = new File("./" + getPropertyFileName()); + if(f.exists()){ + properties.load(new FileInputStream(f)); + loadConfigHistory(properties); + return true; + } + return false; + } + + protected void saveConfig() throws IOException { + Properties properties = new Properties(); + File f = new File("./" + getPropertyFileName()); + if(!f.exists()){ + f.createNewFile(); + } + saveConfigHistory(properties); + properties.store(new FileOutputStream(f), "Generated at " + new Date()); + message("History file written to " + f.getAbsolutePath()); + } + + public void message(String message) throws IOException { + synchronized (output){ + output.println(cli_reset() + "\t>> " + message); + } + } + + public void error(String message, Throwable t) throws IOException { + synchronized (output){ + output.println(cli_red() + "\t>> ERROR " + message); + if(t != null){ + output.println(cli_red() + "\t>> REASON " + t.getMessage()); + t.printStackTrace(); + } + } + } + + protected String cli_reset(){ + return colors ? "\u001B[0m" : ""; + } + + protected String cli_red(){ + return colors ? "\u001B[31m" : ""; + } + + protected String cli_green(){ + return colors ? "\u001B[32m" : ""; + } + + protected String cli_blue(){ + return colors ? "\u001B[34m" : ""; + } + + public void command() throws Exception { + String command = null; + do { + command = captureMandatoryString(input, output, "Please enter a command (or type HELP)"); + try { + if(!processCommand(command)) + closed = true; + } catch(IllegalArgumentException e){ + error("command not found - (type HELP for command list)", null); + } + } while(!closed); + } + + protected boolean validHost(){ + if(hostName == null) return false; + if(hostName.trim().length() == 0) return false; + Pattern p = Pattern.compile("[^a-zA-Z0-9.-]"); + return !p.matcher(hostName).find(); + } + + protected boolean validClientId(){ + if(clientId == null) return false; + if(clientId.trim().length() == 0) return false; + Pattern p = Pattern.compile("[a-zA-Z0-9\\-]{1,1024}"); + return p.matcher(clientId).find(); + } + + protected String getCLIName(){ + return "org.slj interactive command line"; + } + + protected void resetMetrics() throws IOException { + receivedPublishBytesCount.set(0); + sentCount.set(0); + receiveCount.set(0); + sentByteCount.set(0); + receiveByteCount.set(0); + publishedBytesCount.set(0); + message("Metrics & queue reset"); + } + + protected void predefine(String topicName, int alias) + throws IOException { + if(runtime != null && runtimeRegistry != null){ + getOptions().getPredefinedTopics().put(topicName, alias); + message("DONE - predefined topic registered successfully"); + } else { + message("Cannot add a topic to an uninitialised runtime"); + } + } + + protected void stats() throws IOException { + message(String.format("Publish Sent Count: %s messages(s) - (%s bytes) ", sentCount.get(), publishedBytesCount.get())); + message(String.format("Publish Receive Count: %s messages(s) - (%s bytes)", receiveCount.get(), receivedPublishBytesCount.get())); + message(String.format("Network Bytes Sent: %s byte(s)", sentByteCount.get())); + message(String.format("Network Bytes Received: %s byte(s)", receiveByteCount.get())); + } + + protected String captureMandatoryString(Scanner input, PrintStream output, String question){ + String value = null; + while(value == null){ + output.print(cli_reset() + question + " : "); + value = input.nextLine(); + value = value.trim(); + if(value.length() == 0) { + value = null; + } + } + return value; + } + + protected int captureMandatoryInt(Scanner input, PrintStream output, String question, int[] allowedValues){ + String value = null; + while(value == null){ + output.print(cli_reset() + question + " : "); + value = input.nextLine(); + value = value.trim(); + try { + int val = Integer.parseInt(value); + if(allowedValues != null){ + for (int a : allowedValues){ + if(val == a) return val; + } + value = null; + } else { + return val; + } + } catch(NumberFormatException e){ + value = null; + } + } + throw new RuntimeException("cant happen"); + } + + protected boolean captureMandatoryBoolean(Scanner input, PrintStream output, String question){ + String value = null; + while(value == null){ + output.print(cli_reset() + question + " : "); + value = input.nextLine(); + value = value.trim(); + if(value.toLowerCase().equals("y")) return true; + if(value.toLowerCase().equals("n")) return false; + if(value.toLowerCase().equals("yes")) return true; + if(value.toLowerCase().equals("no")) return false; + if(value.toLowerCase().equals("true") || value.toLowerCase().equals("false")){ + return Boolean.parseBoolean(value); + } + value = null; + } + throw new RuntimeException("cant happen"); + } + + public void stop() + throws MqttsnException, IOException { + if(runtime != null){ + runtime.close(); + } + } + + protected void quit() + throws MqttsnException, IOException { + if(runtime != null){ + stop(); + message("stopped - bye :-)"); + } + } + + protected abstract String getPropertyFileName(); + + protected abstract boolean processCommand(String command) throws Exception; + + protected abstract MqttsnOptions createOptions() throws UnknownHostException; + + protected abstract IMqttsnTransport createTransport(); + + protected abstract AbstractMqttsnRuntimeRegistry createRuntimeRegistry(MqttsnOptions options, IMqttsnTransport transport); + + protected abstract AbstractMqttsnRuntime createRuntime(AbstractMqttsnRuntimeRegistry registry, MqttsnOptions options); + + protected AbstractMqttsnRuntimeRegistry getRuntimeRegistry(){ + return runtimeRegistry; + } + + protected AbstractMqttsnRuntime getRuntime(){ + return runtime; + } + + protected MqttsnOptions getOptions(){ + return options; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnBackoffThreadService.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnBackoffThreadService.java new file mode 100644 index 00000000..ff1c8b53 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnBackoffThreadService.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.MqttsnService; + +import java.util.logging.Level; + +public abstract class AbstractMqttsnBackoffThreadService + extends MqttsnService implements Runnable { + + private Thread t; + private Object monitor = new Object(); + + @Override + public synchronized void start(T runtime) throws MqttsnException { + super.start(runtime); + initThread(); + } + + protected void initThread(){ + if(t == null){ + String name = getDaemonName(); + name = name == null ? getClass().getSimpleName().toLowerCase() : name; + String threadName = String.format("mqtt-sn-deamon-%s", name); + t = new Thread(registry.getRuntime().getThreadGroup(), this, threadName); + t.setPriority(Thread.MIN_PRIORITY); + t.setDaemon(true); + t.start(); + t.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + logger.log(Level.SEVERE, "uncaught error on deamon process;", e); + } + }); + } + } + + @Override + public void stop() throws MqttsnException { + super.stop(); + t = null; + } + + @Override + public final void run() { + int count = 1; + try { + registry.getRuntime().joinStartup(); + } catch(Exception e){ + Thread.currentThread().interrupt(); + throw new RuntimeException("error joining startup", e); + } + + logger.log(Level.INFO, String.format("starting thread [%s] processing", Thread.currentThread().getName())); + while(running && + !Thread.currentThread().isInterrupted()){ + long maxBackoff = doWork(); + long waitStart = System.currentTimeMillis(); + synchronized (monitor){ + try { + monitor.wait(Math.max(1, maxBackoff)); + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, + String.format("worker [%s] waited for [%s] in the end", Thread.currentThread().getName(), System.currentTimeMillis() - waitStart)); + } + } catch(InterruptedException e){ + Thread.currentThread().interrupt(); + } + } + } + logger.log(Level.WARNING, String.format("stopped %s thread", Thread.currentThread().getName())); + } + + /** + * Complete your tasks in this method. + * WARNING, throwing an unchecked exception from this method will cause the service to shutdown + */ + protected abstract long doWork(); + + /** + * The name of the deamon will be used in instrumentation and logging + * @return The name of your deamon process + */ + protected abstract String getDaemonName(); + + protected void expedite(){ + synchronized (monitor){ + monitor.notifyAll(); + } + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageHandler.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageHandler.java new file mode 100644 index 00000000..860e0a19 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageHandler.java @@ -0,0 +1,623 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.model.TopicInfo; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.utils.MqttsnUtils; +import org.slj.mqtt.sn.wire.version1_2.payload.*; + +import java.util.List; +import java.util.logging.Level; + +public abstract class AbstractMqttsnMessageHandler + extends MqttsnService implements IMqttsnMessageHandler { + + public boolean temporaryAuthorizeContext(INetworkContext context) { + try { + IMqttsnContext mqttsnContext = registry.getContextFactory().createTemporaryApplicationContext(context); + if(mqttsnContext != null){ + registry.getNetworkRegistry().bindContexts(context, mqttsnContext); + return true; + } + logger.log(Level.WARNING, String.format("context factory did not provide temporary secured context, refuse auth")); + return false; + } + catch(MqttsnSecurityException e){ + logger.log(Level.WARNING, String.format("security exception detected, refuse auth"), e); + return false; + } + } + + public boolean authorizeContext(INetworkContext context, String clientId) { + try { + registry.getNetworkRegistry().removeExistingClientId(clientId); + IMqttsnContext mqttsnContext = registry.getContextFactory().createInitialApplicationContext(context, clientId); + if(mqttsnContext != null){ + registry.getNetworkRegistry().bindContexts(context, mqttsnContext); + return true; + } + logger.log(Level.WARNING, String.format("context factory did not provide secured context, refuse auth")); + return false; + } + catch(MqttsnSecurityException e){ + logger.log(Level.WARNING, String.format("security exception detected, refuse auth"), e); + return false; + } + } + + @Override + public boolean canHandle(IMqttsnContext context, IMqttsnMessage message){ + return true; + } + + @Override + public boolean validResponse(IMqttsnMessage request, IMqttsnMessage response) { + Class[] clz = getResponseClasses(request); + return MqttsnUtils.contains(clz, response.getClass()); + } + + private Class[] getResponseClasses(IMqttsnMessage message) { + + if(!requiresResponse(message)){ + return new Class[0]; + } + switch(message.getMessageType()){ + case MqttsnConstants.CONNECT: + return new Class[]{ MqttsnConnack.class }; + case MqttsnConstants.PUBLISH: + return new Class[]{ MqttsnPuback.class, MqttsnPubrec.class, MqttsnPubrel.class, MqttsnPubcomp.class }; + case MqttsnConstants.PUBREC: + return new Class[]{ MqttsnPubrel.class }; + case MqttsnConstants.PUBREL: + return new Class[]{ MqttsnPubcomp.class }; + case MqttsnConstants.SUBSCRIBE: + return new Class[]{ MqttsnSuback.class }; + case MqttsnConstants.UNSUBSCRIBE: + return new Class[]{ MqttsnUnsuback.class }; + case MqttsnConstants.REGISTER: + return new Class[]{ MqttsnRegack.class }; + case MqttsnConstants.PINGREQ: + return new Class[]{ MqttsnPingresp.class }; + case MqttsnConstants.DISCONNECT: + return new Class[]{ MqttsnDisconnect.class }; + case MqttsnConstants.SEARCHGW: + return new Class[]{ MqttsnGwInfo.class }; + case MqttsnConstants.WILLMSGREQ: + return new Class[]{ MqttsnWillmsg.class }; + case MqttsnConstants.WILLTOPICREQ: + return new Class[]{ MqttsnWilltopic.class }; + case MqttsnConstants.WILLTOPICUPD: + return new Class[]{ MqttsnWilltopicresp.class }; + case MqttsnConstants.WILLMSGUPD: + return new Class[]{ MqttsnWillmsgresp.class }; + default: + throw new MqttsnRuntimeException( + String.format("invalid message type detected [%s], non terminal and non response!", message.getMessageName())); + } + } + + @Override + public boolean isTerminalMessage(IMqttsnMessage message) { + switch(message.getMessageType()){ + case MqttsnConstants.PUBLISH: + MqttsnPublish publish = (MqttsnPublish) message; + return publish.getQoS() <= 0; + case MqttsnConstants.CONNACK: + case MqttsnConstants.PUBACK: //we delete QoS 1 sent PUBLISH on receipt of PUBACK + case MqttsnConstants.PUBREL: //we delete QoS 2 sent PUBLISH on receipt of PUBREL + case MqttsnConstants.UNSUBACK: + case MqttsnConstants.SUBACK: + case MqttsnConstants.ADVERTISE: + case MqttsnConstants.REGACK: + case MqttsnConstants.PUBCOMP: //we delete QoS 2 received PUBLISH on receipt of PUBCOMP + case MqttsnConstants.PINGRESP: + case MqttsnConstants.DISCONNECT: + case MqttsnConstants.ENCAPSMSG: + case MqttsnConstants.GWINFO: + case MqttsnConstants.WILLMSG: + case MqttsnConstants.WILLMSGRESP: + case MqttsnConstants.WILLTOPIC: + case MqttsnConstants.WILLTOPICRESP: + return true; + default: + return false; + } + } + + @Override + public boolean requiresResponse(IMqttsnMessage message) { + switch(message.getMessageType()){ + case MqttsnConstants.PUBLISH: + MqttsnPublish publish = (MqttsnPublish) message; + return publish.getQoS() > 0; + case MqttsnConstants.CONNECT: + case MqttsnConstants.PUBREC: + case MqttsnConstants.PUBREL: + case MqttsnConstants.SUBSCRIBE: + case MqttsnConstants.UNSUBSCRIBE: + case MqttsnConstants.REGISTER: + case MqttsnConstants.PINGREQ: + case MqttsnConstants.DISCONNECT: + case MqttsnConstants.SEARCHGW: + case MqttsnConstants.WILLMSGREQ: + case MqttsnConstants.WILLMSGUPD: + case MqttsnConstants.WILLTOPICREQ: + case MqttsnConstants.WILLTOPICUPD: + return true; + default: + return false; + } + } + + @Override + public boolean isPartOfOriginatingMessage(IMqttsnMessage message) { + switch(message.getMessageType()){ + case MqttsnConstants.PUBLISH: + case MqttsnConstants.CONNECT: + case MqttsnConstants.SUBSCRIBE: + case MqttsnConstants.UNSUBSCRIBE: + case MqttsnConstants.REGISTER: + case MqttsnConstants.PINGREQ: + case MqttsnConstants.DISCONNECT: + case MqttsnConstants.SEARCHGW: + case MqttsnConstants.WILLMSGREQ: + case MqttsnConstants.WILLMSGUPD: + case MqttsnConstants.WILLTOPICREQ: + case MqttsnConstants.WILLTOPICUPD: + return true; + default: + return false; + } + } + + @Override + public void receiveMessage(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException { + + try { + + if(!canHandle(context, message)){ + logger.log(Level.WARNING, String.format("mqtt-sn handler [%s] dropping message it could not handle [%s]", + context, message.getMessageName())); + return; + } + + int msgType = message.getMessageType(); + + if(message.isErrorMessage()){ + logger.log(Level.WARNING, String.format("mqtt-sn handler [%s] received error message [%s]", + context, message)); + } + + beforeHandle(context, message); + + if(logger.isLoggable(Level.INFO)){ + logger.log(Level.INFO, String.format("mqtt-sn handler [%s] handling inbound message [%s]", + context, message)); + } + + boolean errord = false; + IMqttsnMessage originatingMessage = null; + + if(registry.getMessageStateService() != null){ + try { + originatingMessage = + registry.getMessageStateService().notifyMessageReceived(context, message); + } catch(MqttsnException e){ + errord = true; + logger.log(Level.WARNING, String.format("mqtt-sn state service errord, allow message lifecycle to handle [%s] -> [%s]", + context, e.getMessage())); + } + } + + IMqttsnMessage response = handle(context, originatingMessage, message, errord); + + //-- if the state service threw a wobbler but for some reason this didnt lead to an error message + //-- we should just disconnect the device + if(errord && !response.isErrorMessage()){ + logger.log(Level.WARNING, String.format("mqtt-sn state service errord, message handler did not produce an error, so overrule and disconnect [%s] -> [%s]", + context, message)); + response = registry.getMessageFactory().createDisconnect(); + } + + //-- this tidies up inflight if there are errors + afterHandle(context, message, response); + + if (response != null) { + if (response.needsMsgId() && response.getMsgId() == 0) { + int msgId = message.getMsgId(); + response.setMsgId(msgId); + } + + handleResponse(context, response); + } + + afterResponse(context, message, response); + + } catch(MqttsnException e){ + logger.log(Level.WARNING,"handled with disconnect error encountered during receive;", e); + handleResponse(context, + registry.getMessageFactory().createDisconnect()); + if(!registry.getRuntime().handleLocalDisconnect(context, e)) { + throw e; + } + } + } + + protected void beforeHandle(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + + } + + protected IMqttsnMessage handle(IMqttsnContext context, IMqttsnMessage originatingMessage, IMqttsnMessage message, boolean errord) + throws MqttsnException { + + IMqttsnMessage response = null; + int msgType = message.getMessageType(); + + switch (msgType) { + case MqttsnConstants.CONNECT: + response = handleConnect(context, message); + if(!errord && !response.isErrorMessage()){ + registry.getRuntime().handleConnected(context); + } + break; + case MqttsnConstants.CONNACK: + if(validateOriginatingMessage(context, originatingMessage, message)){ + handleConnack(context, originatingMessage, message); + if(!errord && !message.isErrorMessage()){ + registry.getRuntime().handleConnected(context); + } + } + break; + case MqttsnConstants.PUBLISH: + if(errord){ + response = getRegistry().getMessageFactory().createPuback(0, + MqttsnConstants.RETURN_CODE_SERVER_UNAVAILABLE); + } else { + response = handlePublish(context, message); + } + break; + case MqttsnConstants.PUBREC: + response = handlePubrec(context, message); + break; + case MqttsnConstants.PUBREL: + response = handlePubrel(context, message); + break; + case MqttsnConstants.PUBACK: + if(validateOriginatingMessage(context, originatingMessage, message)){ + handlePuback(context, originatingMessage, message); + } + break; + case MqttsnConstants.PUBCOMP: + if(validateOriginatingMessage(context, originatingMessage, message)){ + handlePubcomp(context, originatingMessage, message); + } + break; + case MqttsnConstants.SUBSCRIBE: + if(errord){ + response = getRegistry().getMessageFactory().createSuback(0, 0, + MqttsnConstants.RETURN_CODE_SERVER_UNAVAILABLE); + } else { + response = handleSubscribe(context, message); + } + break; + case MqttsnConstants.UNSUBSCRIBE: + response = handleUnsubscribe(context, message); + break; + case MqttsnConstants.UNSUBACK: + if(validateOriginatingMessage(context, originatingMessage, message)){ + if(!errord){ + handleUnsuback(context, originatingMessage, message); + } + } + break; + case MqttsnConstants.SUBACK: + if(validateOriginatingMessage(context, originatingMessage, message)){ + if(!errord){ + handleSuback(context, originatingMessage, message); + } + } + break; + case MqttsnConstants.REGISTER: + if(errord){ + response = getRegistry().getMessageFactory().createRegack(0, + MqttsnConstants.RETURN_CODE_SERVER_UNAVAILABLE); + } else { + response = handleRegister(context, message); + } + break; + case MqttsnConstants.REGACK: + if(validateOriginatingMessage(context, originatingMessage, message)){ + if(!errord){ + handleRegack(context, originatingMessage, message); + } + } + break; + case MqttsnConstants.PINGREQ: + response = handlePingreq(context, message); + break; + case MqttsnConstants.PINGRESP: + if(validateOriginatingMessage(context, originatingMessage, message)){ + handlePingresp(context, originatingMessage, message); + } + break; + case MqttsnConstants.DISCONNECT: + response = handleDisconnect(context, originatingMessage, message); + break; + case MqttsnConstants.ADVERTISE: + handleAdvertise(context, message); + break; + case MqttsnConstants.ENCAPSMSG: + handleEncapsmsg(context, message); + break; + case MqttsnConstants.GWINFO: + handleGwinfo(context, message); + break; + case MqttsnConstants.SEARCHGW: + response = handleSearchGw(context, message); + break; + case MqttsnConstants.WILLMSGREQ: + response = handleWillmsgreq(context, message); + break; + case MqttsnConstants.WILLMSG: + handleWillmsg(context, message); + break; + case MqttsnConstants.WILLMSGUPD: + response = handleWillmsgupd(context, message); + break; + case MqttsnConstants.WILLMSGRESP: + handleWillmsgresp(context, message); + break; + case MqttsnConstants.WILLTOPICREQ: + response = handleWilltopicreq(context, message); + break; + case MqttsnConstants.WILLTOPIC: + handleWilltopic(context, message); + break; + case MqttsnConstants.WILLTOPICUPD: + response = handleWilltopicupd(context, message); + break; + case MqttsnConstants.WILLTOPICRESP: + handleWilltopicresp(context, message); + break; + default: + throw new MqttsnException("unable to handle unknown message type " + msgType); + } + + return response; + } + + + protected void afterHandle(IMqttsnContext context, IMqttsnMessage message, IMqttsnMessage response) throws MqttsnException { + + if(response != null && response.isErrorMessage()){ + //we need to remove any message that was marked inflight + if(message.needsMsgId()){ + if(registry.getMessageStateService().removeInflight(context, message.getMsgId()) != null){ + logger.log(Level.WARNING, "tidied up bad message that was marked inflight and yeilded error response"); + } + } + } + } + + protected void afterResponse(IMqttsnContext context, IMqttsnMessage message, IMqttsnMessage response) throws MqttsnException { + } + + protected boolean validateOriginatingMessage(IMqttsnContext context, IMqttsnMessage originatingMessage, IMqttsnMessage message) { + if(originatingMessage == null){ + logger.log(Level.WARNING, String.format("[%s] no originating message found for acknowledgement [%s]; reaper probably moved this back to queue", context, message)); + return false; + } + return true; + } + + protected void handleResponse(IMqttsnContext context, IMqttsnMessage response) + throws MqttsnException { + + logger.log(Level.INFO, String.format("mqtt-sn handler [%s] sending outbound message [%s]", + context, response)); + registry.getTransport().writeToTransport( + registry.getNetworkRegistry().getContext(context), response); + } + + protected IMqttsnMessage handleConnect(IMqttsnContext context, IMqttsnMessage connect) throws MqttsnException { + + MqttsnConnect connectMessage = (MqttsnConnect) connect ; + if(connectMessage.isWill()){ + return registry.getMessageFactory().createWillTopicReq(); + } else { + return registry.getMessageFactory().createConnack(MqttsnConstants.RETURN_CODE_ACCEPTED); + } + } + + protected void handleConnack(IMqttsnContext context, IMqttsnMessage connect, IMqttsnMessage connack) throws MqttsnException { + } + + protected IMqttsnMessage handleDisconnect(IMqttsnContext context, IMqttsnMessage originatingMessage, IMqttsnMessage message) throws MqttsnException, MqttsnCodecException { + + //-- if the disconnect is received in response to a disconnect we sent, lets not send another! + if(originatingMessage != null){ + logger.log(Level.INFO, "disconnect received in response to my disconnect, dont send another!"); + return null; + } else { + if(registry.getRuntime().handleRemoteDisconnect(context)){ + return registry.getMessageFactory().createDisconnect(); + } + return null; + } + } + + protected IMqttsnMessage handlePingreq(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException, MqttsnCodecException { + return registry.getMessageFactory().createPingresp(); + } + + protected void handlePingresp(IMqttsnContext context, IMqttsnMessage originatingMessage, IMqttsnMessage message) throws MqttsnException { + } + + protected IMqttsnMessage handleSubscribe(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException, MqttsnCodecException { + + MqttsnSubscribe subscribe = (MqttsnSubscribe) message; + return registry.getMessageFactory().createSuback(subscribe.getQoS(), 0x00, MqttsnConstants.RETURN_CODE_ACCEPTED); + } + + protected IMqttsnMessage handleUnsubscribe(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException, MqttsnCodecException { + MqttsnUnsubscribe unsubscribe = (MqttsnUnsubscribe) message; + return registry.getMessageFactory().createUnsuback(); + } + + protected void handleSuback(IMqttsnContext context, IMqttsnMessage initial, IMqttsnMessage message) throws MqttsnException { + MqttsnSuback suback = (MqttsnSuback) message; + if(!suback.isErrorMessage()){ + MqttsnSubscribe subscribe = (MqttsnSubscribe) initial; + String topicPath = null; + if(subscribe.getTopicType() == MqttsnConstants.TOPIC_NORMAL){ + topicPath = subscribe.getTopicName(); + registry.getTopicRegistry().register(context, topicPath, suback.getTopicId()); + } else { + topicPath = registry.getTopicRegistry().topicPath(context, + registry.getTopicRegistry().normalize((byte) subscribe.getTopicType(), subscribe.getTopicData(), false), false); + } + + registry.getSubscriptionRegistry().subscribe(context, topicPath, suback.getQoS()); + } + } + + protected void handleUnsuback(IMqttsnContext context, IMqttsnMessage unsubscribe, IMqttsnMessage unsuback) throws MqttsnException { + String topicPath = ((MqttsnUnsubscribe)unsubscribe).getTopicName(); + registry.getSubscriptionRegistry().unsubscribe(context, topicPath); + } + + protected IMqttsnMessage handleRegister(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException, MqttsnCodecException { + + MqttsnRegister register = (MqttsnRegister) message; + return registry.getMessageFactory().createRegack(register.getTopicId(), MqttsnConstants.RETURN_CODE_ACCEPTED); + } + + protected void handleRegack(IMqttsnContext context, IMqttsnMessage register, IMqttsnMessage regack) throws MqttsnException { + + String topicPath = ((MqttsnRegister)register).getTopicName(); + registry.getTopicRegistry().register(context, topicPath, ((MqttsnRegack)regack).getTopicId()); + } + + protected IMqttsnMessage handlePublish(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException, MqttsnCodecException { + + MqttsnPublish publish = (MqttsnPublish) message; + IMqttsnMessage response = null; + + TopicInfo info = registry.getTopicRegistry().normalize((byte) publish.getTopicType(), publish.getTopicData(), false); + String topicPath = registry.getTopicRegistry().topicPath(context, info, true); + if(registry.getPermissionService() != null){ + if(!registry.getPermissionService().allowedToPublish(context, topicPath, publish.getData().length, publish.getQoS())){ + logger.log(Level.WARNING, String.format("permissions service rejected publish from [%s] to [%s]", context, topicPath)); + response = registry.getMessageFactory().createPuback(publish.readTopicDataAsInteger(), + MqttsnConstants.RETURN_CODE_REJECTED_CONGESTION); + } + } + + if(response == null){ + switch (publish.getQoS()) { + case MqttsnConstants.QoS1: + response = registry.getMessageFactory().createPuback(publish.readTopicDataAsInteger(), MqttsnConstants.RETURN_CODE_ACCEPTED); + break; + case MqttsnConstants.QoS2: + response = registry.getMessageFactory().createPubrec(); + break; + + default: + case MqttsnConstants.QoSM1: + case MqttsnConstants.QoS0: + break; + } + } + return response; + } + + protected void handlePuback(IMqttsnContext context, IMqttsnMessage originatingMessage, IMqttsnMessage message) + throws MqttsnException { + } + + protected IMqttsnMessage handlePubrel(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException, MqttsnCodecException { + return registry.getMessageFactory().createPubcomp(); + } + + protected IMqttsnMessage handlePubrec(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException, MqttsnCodecException { + return registry.getMessageFactory().createPubrel(); + } + + protected void handlePubcomp(IMqttsnContext context, IMqttsnMessage originatingMessage, IMqttsnMessage message) + throws MqttsnException { + } + + protected void handleAdvertise(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + + } + + protected void handleEncapsmsg(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + } + + protected IMqttsnMessage handleSearchGw(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + return null; + } + + protected void handleGwinfo(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + } + + protected IMqttsnMessage handleWillmsgreq(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + return null; + } + + protected void handleWillmsg(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + } + + protected IMqttsnMessage handleWillmsgupd(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + return null; + } + + protected void handleWillmsgresp(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + } + + protected IMqttsnMessage handleWilltopicreq(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + return null; + } + + protected void handleWilltopic(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + } + + protected IMqttsnMessage handleWilltopicupd(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + return null; + } + + protected void handleWilltopicresp(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageRegistry.java new file mode 100644 index 00000000..0ec3e523 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageRegistry.java @@ -0,0 +1,117 @@ +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.*; + +import java.util.Date; +import java.util.UUID; + +public abstract class AbstractMqttsnMessageRegistry + extends MqttsnService implements IMqttsnMessageRegistry { + + @Override + public UUID add(byte[] data, boolean removeAfterRead) throws MqttsnException { + UUID messageId = UUID.randomUUID(); + MessageImpl impl = new MessageImpl(messageId, data, removeAfterRead); + return storeInternal(impl); + } + + @Override + public UUID add(byte[] data, Date expires) throws MqttsnException { + UUID messageId = UUID.randomUUID(); + MessageImpl impl = new MessageImpl(messageId, data, expires); + return storeInternal(impl); + } + + @Override + public byte[] get(UUID messageId) throws MqttsnException { + + MessageImpl impl = readInternal(messageId); + if(impl != null){ + Date expires = impl.getExpires(); + if(expires != null && expires.before(new Date())){ + remove(messageId); + impl = null; + } + } + if(impl == null) throw new MqttsnExpectationFailedException("unable to read message by id, message not found in registry"); + if(impl.isRemoveAfterRead()){ + remove(messageId); + } + return impl.getData(); + } + + + + @Override + public void clear(IMqttsnContext context) throws MqttsnException { + throw new UnsupportedOperationException("message registry is global"); + } + + protected abstract boolean remove(UUID messageId) throws MqttsnException; + + protected abstract UUID storeInternal(MessageImpl message) throws MqttsnException; + + protected abstract MessageImpl readInternal(UUID messageId) throws MqttsnException; + + protected static class MessageImpl { + + Date created; + Date expires; + UUID messageId; + byte[] data; + boolean removeAfterRead = false; + + public MessageImpl(UUID messageId, byte[] data, boolean removeAfterRead) { + this(messageId, data, null); + this.removeAfterRead = removeAfterRead; + } + + public MessageImpl(UUID messageId, byte[] data, Date expires) { + this.created = new Date(); + this.expires = expires; + this.messageId = messageId; + this.data = data; + } + + public boolean isRemoveAfterRead() { + return removeAfterRead; + } + + public void setRemoveAfterRead(boolean removeAfterRead) { + this.removeAfterRead = removeAfterRead; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getExpires() { + return expires; + } + + public void setExpires(Date expires) { + this.expires = expires; + } + + public UUID getMessageId() { + return messageId; + } + + public void setMessageId(UUID messageId) { + this.messageId = messageId; + } + + public byte[] getData() { + return data; + } + + public void setData(byte[] data) { + this.data = data; + } + } +} \ No newline at end of file diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageStateService.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageStateService.java new file mode 100644 index 00000000..6d5d76c9 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnMessageStateService.java @@ -0,0 +1,775 @@ +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.PublishData; +import org.slj.mqtt.sn.model.*; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.utils.MqttsnUtils; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.payload.*; + +import java.util.*; +import java.util.concurrent.*; +import java.util.logging.Level; + +public abstract class AbstractMqttsnMessageStateService + extends AbstractMqttsnBackoffThreadService implements IMqttsnMessageStateService { + + static final int MAX_BACKOFF_INCR = 10; + protected static final Integer WEAK_ATTACH_ID = new Integer(MqttsnConstants.USIGNED_MAX_16 + 1); + protected boolean clientMode; + + protected Map lastActiveMessage; + protected Map lastMessageSent; + protected Map lastMessageReceived; + + protected Map lastUsedMsgIds; + protected Map> flushOperations; + protected ScheduledExecutorService executorService = null; + + public AbstractMqttsnMessageStateService(boolean clientMode) { + this.clientMode = clientMode; + } + + @Override + public synchronized void start(T runtime) throws MqttsnException { + flushOperations = new HashMap(); + executorService = Executors.newScheduledThreadPool( + runtime.getOptions().getStateProcessorThreadCount()); + + lastUsedMsgIds = Collections.synchronizedMap(new HashMap()); + lastMessageReceived = Collections.synchronizedMap(new HashMap()); + lastMessageSent = Collections.synchronizedMap(new HashMap()); + lastActiveMessage = Collections.synchronizedMap(new HashMap()); + super.start(runtime); + } + + @Override + public void stop() throws MqttsnException { + super.stop(); + try { + if(!executorService.isShutdown()){ + executorService.shutdown(); + } + executorService.awaitTermination(10, TimeUnit.SECONDS); + } catch(InterruptedException e){ + Thread.currentThread().interrupt(); + } finally { + if (!executorService.isTerminated()) { + executorService.shutdownNow(); + } + } + } + + protected void scheduleWork(IMqttsnContext context, int time, TimeUnit unit){ + ScheduledFuture future = + executorService.schedule(() -> { + IMqttsnMessageQueueProcessor.RESULT result = + IMqttsnMessageQueueProcessor.RESULT.REMOVE_PROCESS; + boolean process = !flushOperations.containsKey(context) || + !flushOperations.get(context).isDone(); + logger.log(Level.INFO, String.format("processing scheduled work for context [%s] -> [%s]", context, process)); + if(process){ + result = processQueue(context); + switch(result){ + case REMOVE_PROCESS: + synchronized (flushOperations){ + flushOperations.remove(context); + } + logger.log(Level.FINE, String.format("removed context from work list [%s]", context)); + break; + case BACKOFF_PROCESS: + Long lastReceived = lastMessageReceived.get(context); + long delta = lastReceived == null ? 0 : System.currentTimeMillis() - lastReceived; + boolean remove = registry.getOptions().getActiveContextTimeout() < delta; + logger.log(Level.INFO, String.format("backoff requested for [%s], activity delta is [%s], remove work ? [%s]", context, delta, remove)); + if(remove){ + synchronized (flushOperations){ + flushOperations.remove(context); + } + } + else { + scheduleWork(context, Math.max(1000, registry.getOptions().getMinFlushTime()), TimeUnit.MILLISECONDS); + } + break; + case REPROCESS: + scheduleWork(context, registry.getOptions().getMinFlushTime(), TimeUnit.MILLISECONDS); + } + } + return result; + }, time, unit); + synchronized (flushOperations){ + flushOperations.put(context, future); + } + } + + protected IMqttsnMessageQueueProcessor.RESULT processQueue(IMqttsnContext context){ + try { + return registry.getQueueProcessor().process(context); + } catch(Exception e){ + logger.log(Level.SEVERE, + String.format("error encountered processing queue for [%s]", context), e); + return IMqttsnMessageQueueProcessor.RESULT.REMOVE_PROCESS; + } + } + + @Override + public void unscheduleFlush(IMqttsnContext context) { + ScheduledFuture future = null; + if(!flushOperations.containsKey(context)) { + synchronized (flushOperations) { + future = flushOperations.remove(context); + } + } + if(future != null){ + if(!future.isDone()){ + future.cancel(false); + } + } + } + + @Override + public void scheduleFlush(IMqttsnContext context) { + if(!flushOperations.containsKey(context) || + flushOperations.get(context).isDone()){ + logger.log(Level.INFO, String.format("scheduling outbound work for [%s]", context)); + scheduleWork(context, + ThreadLocalRandom.current().nextInt(1, 250), TimeUnit.MILLISECONDS); + } + } + + @Override + protected long doWork() { + + long nextWork = 15000; + + //-- monitor active context timeouts + int activeMessageTimeout = registry.getOptions().getActiveContextTimeout(); + if(activeMessageTimeout > 0){ + synchronized (lastActiveMessage){ + Iterator itr = lastActiveMessage.keySet().iterator(); + while(itr.hasNext()){ + IMqttsnContext context = itr.next(); + Long time = lastActiveMessage.get(context); + if((time + activeMessageTimeout) < System.currentTimeMillis()){ + //-- context is timedout + registry.getRuntime().handleActiveTimeout(context); + itr.remove(); + } + } + } + } + + try { + registry.getMessageRegistry().tidy(); + } catch(Exception e){ + logger.log(Level.SEVERE, "error tidying message registry on state thread;", e); + } + return nextWork; + } + + protected boolean allowedToSend(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + return true; + } + + @Override + public MqttsnWaitToken sendMessage(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + return sendMessageInternal(context, message, null); + } + + @Override + public MqttsnWaitToken sendMessage(IMqttsnContext context, TopicInfo info, QueuedPublishMessage queuedPublishMessage) throws MqttsnException { + + byte[] payload = registry.getMessageRegistry().get(queuedPublishMessage.getMessageId()); + + MqttsnConstants.TOPIC_TYPE type = info.getType(); + int topicId = info.getTopicId(); + if(type == MqttsnConstants.TOPIC_TYPE.SHORT && topicId == 0){ + String topicPath = info.getTopicPath(); + topicId = MqttsnWireUtils.read16bit((byte) topicPath.charAt(0), + (byte) topicPath.charAt(1)); + } + + IMqttsnMessage publish = registry.getMessageFactory().createPublish(queuedPublishMessage.getGrantedQoS(), + queuedPublishMessage.getRetryCount() > 1, false, type, topicId, + payload); + return sendMessageInternal(context, publish, queuedPublishMessage); + } + + protected MqttsnWaitToken sendMessageInternal(IMqttsnContext context, IMqttsnMessage message, QueuedPublishMessage queuedPublishMessage) throws MqttsnException { + + if(!allowedToSend(context, message)){ + logger.log(Level.WARNING, + String.format("allowed to send [%s] check failed [%s]", + message, context)); + throw new MqttsnExpectationFailedException("allowed to send check failed"); + } + + InflightMessage.DIRECTION direction = registry.getMessageHandler().isPartOfOriginatingMessage(message) ? + InflightMessage.DIRECTION.SENDING : InflightMessage.DIRECTION.RECEIVING; + + int count = countInflight(context, direction); + if(count > 0){ + logger.log(Level.WARNING, + String.format("presently unable to send [%s],[%s] to [%s], max inflight reached for direction [%s] [%s] -> [%s]", + message, queuedPublishMessage, context, direction, count, + Objects.toString(getInflightMessages(context)))); + + Optional blockingMessage = + getInflightMessages(context).values().stream().filter(i -> i.getDirection() == direction).findFirst(); + if(blockingMessage.isPresent() && clientMode){ + //-- if we are in client mode, attempt to wait for the ongoing outbound message to complete before we issue next message + MqttsnWaitToken token = blockingMessage.get().getToken(); + if(token != null){ + waitForCompletion(context, token); + if(!token.isError() && token.isComplete()){ + //-- recurse point + return sendMessageInternal(context, message, queuedPublishMessage); + } + else { + logger.log(Level.WARNING, String.format("unable to send, partial send in progress with token [%s]", token)); + throw new MqttsnExpectationFailedException("unable to send message, partial send in progress"); + } + } + } else { + throw new MqttsnExpectationFailedException("max number of inflight messages reached"); + } + } + + try { + + MqttsnWaitToken token = null; + boolean requiresResponse = false; + if((requiresResponse = registry.getMessageHandler().requiresResponse(message))){ + token = markInflight(context, message, queuedPublishMessage); + } + + if(logger.isLoggable(Level.INFO)){ + logger.log(Level.INFO, + String.format("sending message [%s] to [%s] via state service, marking inflight ? [%s]", + message, context, requiresResponse)); + } + + registry.getTransport().writeToTransport(registry.getNetworkRegistry().getContext(context), message); + long time = System.currentTimeMillis(); + if(registry.getCodec().isActiveMessage(message)){ + lastActiveMessage.put(context, time); + } + lastMessageSent.put(context, time); + + //-- the only publish that does not require an ack is QoS so send to app as delivered + if(!requiresResponse && registry.getCodec().isPublish(message)){ + PublishData data = registry.getCodec().getData(message); + CommitOperation op = CommitOperation.outbound(context, queuedPublishMessage.getMessageId(), + queuedPublishMessage.getTopicPath(), queuedPublishMessage.getGrantedQoS(), + data.getData()); + confirmPublish(op); + } + + return token; + + } catch(Exception e){ + throw new MqttsnException("error sending message with confirmations", e); + } + } + + @Override + public Optional waitForCompletion(IMqttsnContext context, final MqttsnWaitToken token) throws MqttsnExpectationFailedException { + return waitForCompletion(context, token, registry.getOptions().getMaxWait()); + } + + @Override + public Optional waitForCompletion(IMqttsnContext context, final MqttsnWaitToken token, int waitTime) throws MqttsnExpectationFailedException { + try { + IMqttsnMessage message = token.getMessage(); + if(token.isComplete()){ + return Optional.ofNullable(message); + } + IMqttsnMessage response = null; + + long start = System.currentTimeMillis(); + long timeToWait = Math.max(waitTime, registry.getOptions().getMaxErrorRetryTime()); + synchronized(token){ + //-- code against spurious wake up + while(!token.isComplete() && + timeToWait > System.currentTimeMillis() - start) { + token.wait(timeToWait); + } + } + + long time = System.currentTimeMillis() - start; + if(token.isComplete()){ + response = token.getResponseMessage(); + if(logger.isLoggable(Level.INFO)){ + logger.log(Level.INFO, String.format("token [%s] in [%s], confirmation of message [%s] -> [%s]", + token.isError() ? "error" : "ok", MqttsnUtils.getDurationString(time), context, response == null ? "" : response)); + } + return Optional.ofNullable(response); + } else { + logger.log(Level.WARNING, String.format("token timed out waiting [%s]ms for response to [%s] in [%s] on thread [%s]", + waitTime, + message, + MqttsnUtils.getDurationString(time), Thread.currentThread().getName())); + token.markError(); + + //a timeout should unblock the sender UNLESS its a PUBLISH in which case this is the jod of the + //reaper (should it be? - surely the sender should monitor..) + try { + clearInflight(context); + } catch(Exception e){ + logger.log(Level.SEVERE, "error cleaning inflight on timeout"); + } + throw new MqttsnExpectationFailedException("unable to obtain response within timeout ("+waitTime+")"); + } + + } catch(InterruptedException e){ + logger.log(Level.WARNING, "a thread waiting for a message being sent was interrupted;", e); + Thread.currentThread().interrupt(); + throw new MqttsnRuntimeException(e); + } + } + + @Override + public IMqttsnMessage notifyMessageReceived(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + + if(registry.getCodec().isActiveMessage(message)){ + lastActiveMessage.put(context, System.currentTimeMillis()); + } + lastMessageReceived.put(context, System.currentTimeMillis()); + + Integer msgId = message.needsMsgId() ? message.getMsgId() : WEAK_ATTACH_ID; + boolean matchedMessage = inflightExists(context, msgId); + boolean terminalMessage = registry.getMessageHandler().isTerminalMessage(message); + if (matchedMessage) { + if (terminalMessage) { + InflightMessage inflight = removeInflight(context, msgId); + if(inflight == null){ + logger.log(Level.WARNING, + String.format("inflight message was cleared during notifyReceive for [%s] -> [%s]", context, msgId)); + return null; + } + else if (!registry.getMessageHandler().validResponse(inflight.getMessage(), message)) { + logger.log(Level.WARNING, + String.format("invalid response message [%s] for [%s] -> [%s]", + message, inflight.getMessage(), context)); + + if(registry.getCodec().isDisconnect(message)){ + + logger.log(Level.WARNING, + String.format("detected distant disconnect, notify application for [%s] -> [%s]", + inflight.getMessage(), context)); + MqttsnWaitToken token = inflight.getToken(); + if (token != null) { + synchronized (token) { + //-- release any waits + token.setResponseMessage(message); + token.markError(); + token.notifyAll(); + } + } + registry.getRuntime().handleRemoteDisconnect(context); + return null; + } else { + throw new MqttsnRuntimeException("invalid response received " + message.getMessageName()); + } + } else { + + IMqttsnMessage confirmedMessage = inflight.getMessage(); + MqttsnWaitToken token = inflight.getToken(); + + if (token != null) { + synchronized (token) { + //-- release any waits + token.setResponseMessage(message); + if (message.isErrorMessage()) token.markError(); + else token.markComplete(); + token.notifyAll(); + } + } + + if (message.isErrorMessage()) { + + logger.log(Level.WARNING, + String.format("error response received [%s] in response to [%s] for [%s]", + message, confirmedMessage, context)); + + //received an error message in response, if its requeuable do so + if (inflight instanceof RequeueableInflightMessage) { + + try { + QueuedPublishMessage m = ((RequeueableInflightMessage) inflight).getQueuedPublishMessage(); + if(m.getRetryCount() >= registry.getOptions().getMaxErrorRetries()){ + logger.log(Level.WARNING, String.format("publish message [%s] exceeded max retries [%s], discard and notify application", registry.getOptions().getMaxErrorRetries(), m)); + PublishData data = registry.getCodec().getData(confirmedMessage); + registry.getRuntime().messageSendFailure(context, m.getMessageId(), m.getTopicPath(), m.getGrantedQoS(), data.getData(), m.getRetryCount()); + } else { + logger.log(Level.INFO, + String.format("message was re-queueable offer to queue [%s]", context)); + registry.getMessageQueue().offer(context, m); + } + } catch(MqttsnQueueAcceptException e){ + throw new MqttsnException(e); + } + } + + } else { + + //inbound qos 2 commit + if (registry.getCodec().isPubRel(message)) { + PublishData data = registry.getCodec().getData(confirmedMessage); + CommitOperation op = CommitOperation.inbound(context, + confirmedMessage instanceof MqttsnPublish ? getTopicPathFromPublish(context, (MqttsnPublish) confirmedMessage) : null, + data.getQos(), + data.getData()); + confirmPublish(op); + } + + //outbound qos 1 + if (registry.getCodec().isPuback(message)) { + RequeueableInflightMessage rim = (RequeueableInflightMessage) inflight; + PublishData data = registry.getCodec().getData(confirmedMessage); + CommitOperation op = CommitOperation.outbound(context, rim.getQueuedPublishMessage().getMessageId(), + rim.getQueuedPublishMessage().getTopicPath(), rim.getQueuedPublishMessage().getGrantedQoS(), + data.getData()); + confirmPublish(op); + } + } + return confirmedMessage; + } + } else { + + InflightMessage inflight = getInflightMessage(context, msgId); + + //none terminal matched message.. this is fine (PUBREC or PUBREL) + //outbound qos 2 commit point + if(inflight != null && registry.getCodec().isPubRec(message)){ + RequeueableInflightMessage rim = (RequeueableInflightMessage) inflight; + PublishData data = registry.getCodec().getData(inflight.getMessage()); + CommitOperation op = CommitOperation.outbound(context, rim.getQueuedPublishMessage().getMessageId(), + rim.getQueuedPublishMessage().getTopicPath(), rim.getQueuedPublishMessage().getGrantedQoS(), + data.getData()); + confirmPublish(op); + } + + return null; + } + + } else { + + //-- received NEW message that was not associated with an inflight message + //-- so we need to pin it into the inflight system (if it needs confirming). + if (registry.getCodec().isPublish(message)) { + PublishData data = registry.getCodec().getData(message); + if (data.getQos() == 2) { +// int count = countInflight(context, InflightMessage.DIRECTION.RECEIVING); +// if(count >= registry.getOptions().getMaxMessagesInflight()){ +// logger.log(Level.WARNING, String.format("have [%s] existing inbound message(s) inflight & new publish QoS2, replacing inflights!", count)); +// throw new MqttsnException("cannot receive more than maxInflight!"); +// } + //-- Qos 2 needs further confirmation before being sent to application + markInflight(context, message, null); + } else { + //-- Qos 0 & 1 are inbound are confirmed on receipt of message + + CommitOperation op = CommitOperation.inbound(context, + message instanceof MqttsnPublish ? getTopicPathFromPublish(context, (MqttsnPublish) message) : null, + data.getQos(), + data.getData()); + confirmPublish(op); + } + } + return null; + } + } + + /** + * Confirmation delivery to the application takes place on the worker thread group + */ + protected void confirmPublish(final CommitOperation operation) { + getRegistry().getRuntime().async(() -> { + IMqttsnContext context = operation.context; + if(operation.inbound){ + registry.getRuntime().messageReceived(context, operation.topicPath, operation.QoS, operation.payload); + } else { + registry.getRuntime().messageSent(context, operation.messageId, operation.topicPath, operation.QoS, operation.payload); + } + }); + } + + protected MqttsnWaitToken markInflight(IMqttsnContext context, IMqttsnMessage message, QueuedPublishMessage queuedPublishMessage) + throws MqttsnException { + + InflightMessage.DIRECTION direction = + message instanceof MqttsnPublish ? + queuedPublishMessage == null ? + InflightMessage.DIRECTION.RECEIVING : InflightMessage.DIRECTION.SENDING : + registry.getMessageHandler().isPartOfOriginatingMessage(message) ? + InflightMessage.DIRECTION.SENDING : InflightMessage.DIRECTION.RECEIVING; + + //may have old inbound messages kicking around depending on reap settings, to just allow these to come in + if(countInflight(context, direction) >= + registry.getOptions().getMaxMessagesInflight()){ + logger.log(Level.WARNING, String.format("[%s] max inflight message number reached, fail-fast for sending, allow for receiving [%s] - [%s]", context, direction, message)); + if(direction == InflightMessage.DIRECTION.SENDING){ + throw new MqttsnExpectationFailedException("max number of inflight messages reached"); + } + } + + InflightMessage inflight = queuedPublishMessage == null ? new InflightMessage(message, direction, MqttsnWaitToken.from(message)) : + new RequeueableInflightMessage(queuedPublishMessage, message); + + LastIdContext idContext = LastIdContext.from(context, direction); + int msgId = WEAK_ATTACH_ID; + if (message.needsMsgId()) { + if (message.getMsgId() > 0) { + msgId = message.getMsgId(); + } else { + msgId = getNextMsgId(idContext); + message.setMsgId(msgId); + } + } + + addInflightMessage(context, msgId, inflight); + + if(logger.isLoggable(Level.INFO)){ + logger.log(Level.INFO, String.format("[%s] marking [%s] message [%s] inflight id context [%s]", context, + direction, message, idContext)); + } + + if(msgId != WEAK_ATTACH_ID) lastUsedMsgIds.put(idContext, msgId); + return inflight.getToken(); + } + + protected Integer getNextMsgId(LastIdContext context) throws MqttsnException { + + Map map = getInflightMessages(context.context); + int startAt = Math.max(lastUsedMsgIds.get(context) == null ? 1 : lastUsedMsgIds.get(context) + 1, + registry.getOptions().getMsgIdStartAt()); + + Set set = map.keySet(); + while(set.contains(new Integer(startAt))){ + startAt = ++startAt % MqttsnConstants.USIGNED_MAX_16; + } + + if(set.contains(new Integer(startAt))) + throw new MqttsnRuntimeException("cannot assign msg id " + startAt); + + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("next id available for context [%s] is [%s]", context, startAt)); + } + + return startAt; + } + + public void clearInflight(IMqttsnContext context) throws MqttsnException { + clearInflightInternal(context, 0); + clear(context); + } + + protected void clearInflightInternal(IMqttsnContext context, long evictionTime) throws MqttsnException { + logger.log(Level.FINE, String.format("clearing all inflight messages for context [%s], forced = [%s]", context, evictionTime == 0)); + Map messages = getInflightMessages(context); + if(messages != null && !messages.isEmpty()){ + synchronized (messages){ + Iterator messageItr = messages.keySet().iterator(); + while(messageItr.hasNext()){ + Integer i = messageItr.next(); + InflightMessage f = messages.get(i); + if(f != null){ + if(evictionTime == 0 || + f.getTime() + registry.getOptions().getMaxTimeInflight() < evictionTime){ + if(!registry.getOptions().isReapReceivingMessages() && + f.getDirection() == InflightMessage.DIRECTION.RECEIVING){ + continue; + } + messageItr.remove(); + reapInflight(context, f); + } + } + } + } + } + } + + protected void reapInflight(IMqttsnContext context, InflightMessage inflight) throws MqttsnException { + + IMqttsnMessage message = inflight.getMessage(); + logger.log(Level.WARNING, String.format("clearing message [%s] destined for [%s] aged [%s] from inflight", + message, context, MqttsnUtils.getDurationString(System.currentTimeMillis() - inflight.getTime()))); + + MqttsnWaitToken token = inflight.getToken(); + synchronized (token){ + token.markError(); + token.notifyAll(); + } + + //-- requeue if its a PUBLISH and we have a message queue bound + if(inflight instanceof RequeueableInflightMessage){ + RequeueableInflightMessage requeueableInflightMessage = (RequeueableInflightMessage) inflight; + if(registry.getMessageQueue() != null && + registry.getOptions().isRequeueOnInflightTimeout() && + requeueableInflightMessage.getQueuedPublishMessage() != null) { + logger.log(Level.INFO, String.format("re-queuing publish message [%s] for client [%s]", context, + ((RequeueableInflightMessage) inflight).getQueuedPublishMessage())); + QueuedPublishMessage queuedPublishMessage = requeueableInflightMessage.getQueuedPublishMessage(); + queuedPublishMessage.setToken(null); + try { + boolean maxRetries = queuedPublishMessage.getRetryCount() >= registry.getOptions().getMaxErrorRetries(); + if(maxRetries){ + //-- we're disconnecting the runtime, so reset counter for next active session + queuedPublishMessage.setRetryCount(0); + } + registry.getMessageQueue().offer(context, queuedPublishMessage); + if(maxRetries){ + registry.getRuntime().handleConnectionLost(context, null); + } + } catch(MqttsnQueueAcceptException e){ + throw new MqttsnException(e); + } + } + } + } + + @Override + public int countInflight(IMqttsnContext context, InflightMessage.DIRECTION direction) throws MqttsnException { + Map map = getInflightMessages(context); + Iterator itr = map.keySet().iterator(); + int count = 0; + while(itr.hasNext()){ + Integer i = itr.next(); + InflightMessage msg = map.get(i); + if(msg.getDirection() == direction) count++; + } + return count; + } + + @Override + public boolean canSend(IMqttsnContext context) throws MqttsnException { + return countInflight(context, InflightMessage.DIRECTION.SENDING) == 0; + } + + @Override + public Long getMessageLastSentToContext(IMqttsnContext context) { + return lastMessageSent.get(context); + } + + @Override + public Long getMessageLastReceivedFromContext(IMqttsnContext context) { + return lastMessageReceived.get(context); + } + +// @Override +// public Long getContextLastActive(IMqttsnContext context) throws MqttsnException { +// return lastActiveMessage.get(context); +// } + + protected String getTopicPathFromPublish(IMqttsnContext context, MqttsnPublish publish) throws MqttsnException { + TopicInfo info = registry.getTopicRegistry().normalize((byte) publish.getTopicType(), publish.getTopicData(), false); + String topicPath = registry.getTopicRegistry().topicPath(context, info, true); + return topicPath; + } + + protected abstract void addInflightMessage(IMqttsnContext context, Integer messageId, InflightMessage message) throws MqttsnException ; + + protected abstract InflightMessage getInflightMessage(IMqttsnContext context, Integer messageId) throws MqttsnException ; + + protected abstract Map getInflightMessages(IMqttsnContext context) throws MqttsnException; + + protected abstract boolean inflightExists(IMqttsnContext context, Integer messageId) throws MqttsnException; + + static class CommitOperation { + + protected int QoS; + protected String topicPath; + protected byte[] payload; + protected IMqttsnContext context; + protected long timestamp; + protected UUID messageId; + protected boolean inbound = true; + + public CommitOperation(IMqttsnContext context, long timestamp, String topicPath, int QoS, byte[] payload, boolean inbound) { + this.context = context; + this.timestamp = timestamp; + this.inbound = inbound; + this.topicPath = topicPath; + this.payload = payload; + this.QoS = QoS; + } + + public static CommitOperation inbound(IMqttsnContext context, String topicPath, int QoS, byte[] payload){ + return new CommitOperation(context, System.currentTimeMillis(), topicPath, QoS, payload, true); + } + + public static CommitOperation outbound(IMqttsnContext context, UUID messageId, String topicPath, int QoS, byte[] payload){ + CommitOperation c = new CommitOperation(context, System.currentTimeMillis(), topicPath, QoS, payload, false); + c.messageId = messageId; + return c; + } + } + + static class FlushQueueOperation { + + protected final IMqttsnContext context; + protected long timestamp; + protected int count; + + public FlushQueueOperation(IMqttsnContext context, long timestamp){ + this.context = context; + this.timestamp = timestamp; + } + + public void resetCount(){ + timestamp = 0; + count = 0; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FlushQueueOperation that = (FlushQueueOperation) o; + return Objects.equals(context, that.context); + } + + @Override + public int hashCode() { + return Objects.hash(context); + } + } + + static class LastIdContext { + + protected final IMqttsnContext context; + protected final InflightMessage.DIRECTION direction; + + public LastIdContext(IMqttsnContext context, InflightMessage.DIRECTION direction) { + this.context = context; + this.direction = direction; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LastIdContext that = (LastIdContext) o; + return context.equals(that.context) && direction == that.direction; + } + + @Override + public int hashCode() { + return Objects.hash(context, direction); + } + + @Override + public String toString() { + return "LastIdContext{" + + "context=" + context + + ", direction=" + direction + + '}'; + } + + public static LastIdContext from(IMqttsnContext context, InflightMessage.DIRECTION direction){ + return new LastIdContext(context, direction); + } + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnRuntime.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnRuntime.java new file mode 100644 index 00000000..505b70d0 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnRuntime.java @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.utils.StringTable; +import org.slj.mqtt.sn.utils.StringTableWriters; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.locks.Condition; +import java.util.logging.Level; +import java.util.logging.Logger; + +public abstract class AbstractMqttsnRuntime { + + protected Logger logger = Logger.getLogger(getClass().getName()); + protected IMqttsnRuntimeRegistry registry; + + protected List receivedListeners + = new ArrayList<>(); + protected List sentListeners + = new ArrayList<>(); + protected List sendFailureListeners + = new ArrayList<>(); + protected List connectionListeners + = new ArrayList<>(); + + protected List activeServices + = Collections.synchronizedList(new ArrayList<>()); + + private ThreadGroup threadGroup = new ThreadGroup("mqtt-sn"); + private Thread instrumentationThread; + protected ExecutorService executorService; + protected CountDownLatch startupLatch; + protected volatile boolean running = false; + protected long startedAt; + private final Object monitor = new Object(); + + public final void start(IMqttsnRuntimeRegistry reg) throws MqttsnException { + start(reg, false); + } + + public final void start(IMqttsnRuntimeRegistry reg, boolean join) throws MqttsnException { + if(!running){ + int threadCount = reg.getOptions().getHandoffThreadCount(); + executorService = + Executors.newFixedThreadPool(threadCount, new ThreadFactory() { + int count = 0; + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(threadGroup, r, "mqtt-sn-worker-thread-" + ++count); + t.setPriority(Thread.MIN_PRIORITY + 1); + t.setDaemon(true); + return t; + } + }); + startedAt = System.currentTimeMillis(); + setupEnvironment(reg.getOptions()); + registry = reg; + startupLatch = new CountDownLatch(1); + running = true; + registry.setRuntime(this); + registry.init(); + bindShutdownHook(); + logger.log(Level.INFO, "starting mqttsn-environment.."); + startupServices(registry); + if(registry.getOptions().isInstrumentationEnabled()){ + initInstrumentation(); + } + startupLatch.countDown(); + logger.log(Level.INFO, String.format("mqttsn-environment started successfully in [%s]", System.currentTimeMillis() - startedAt)); + if(join){ + while(running){ + synchronized (monitor){ + try { + monitor.wait(); + } catch(InterruptedException e){ + Thread.currentThread().interrupt(); + throw new MqttsnException(e); + } + } + } + } + } + } + + public final void stop() throws MqttsnException { + if(running){ + logger.log(Level.INFO, "stopping mqttsn-environment.."); + stopServices(registry); + running = false; + receivedListeners.clear(); + sentListeners.clear(); + sendFailureListeners.clear(); + try { + if(!executorService.isShutdown()){ + executorService.shutdown(); + } + executorService.awaitTermination(30, TimeUnit.SECONDS); + } catch(InterruptedException e){ + Thread.currentThread().interrupt(); + } finally { + if (!executorService.isTerminated()) { + executorService.shutdownNow(); + } + } + synchronized (monitor){ + monitor.notifyAll(); + } + } + } + + protected void initInstrumentation(){ + if(instrumentationThread != null){ + instrumentationThread = new Thread(getThreadGroup(), () -> { + try { + logger.log(Level.INFO, "mqttsn-environment started instrumentation thread"); + while(running){ + synchronized (instrumentationThread){ + instrumentationThread.wait(registry.getOptions().getInstrumentationInterval()); + activeServices.stream(). + filter(s -> s instanceof IMqttsnInstrumentationProvider). + map(s -> (IMqttsnInstrumentationProvider) s).forEach(s -> { + StringTable st = s.provideInstrumentation(); + if(st != null){ + logger.log(Level.INFO, StringTableWriters.writeStringTableAsASCII(st)); + } + }); + } + } + } + catch(InterruptedException e){ + Thread.currentThread().interrupt(); + } + catch(Exception e){ + logger.log(Level.SEVERE, "encountered error tracking instrumentation;", e); + } + }, "mqtt-sn-instrumentation"); + instrumentationThread.setDaemon(true); + instrumentationThread.setPriority(Thread.MIN_PRIORITY); + instrumentationThread.start(); + } + } + + protected void bindShutdownHook(){ + Runtime.getRuntime().addShutdownHook(new Thread(getThreadGroup(), () -> { + try { + AbstractMqttsnRuntime.this.stop(); + } catch(Exception e){ + logger.log(Level.SEVERE, "encountered error executing shutdown hook", e); + } + }, "mqtt-sn-finalizer")); + } + + protected final void callStartup(Object service) throws MqttsnException { + if(service instanceof IMqttsnService){ + IMqttsnService snService = (IMqttsnService) service; + if(!snService.running()){ + logger.log(Level.INFO, String.format("starting [%s]", service.getClass().getName())); + snService.start(registry); + activeServices.add(snService); + } + } + } + + protected final void callShutdown(Object service) throws MqttsnException { + if(service instanceof IMqttsnService){ + IMqttsnService snService = (IMqttsnService) service; + if(snService.running()){ + logger.log(Level.INFO, String.format("stopping [%s]", service.getClass().getName())); + snService.stop(); + activeServices.remove(snService); + } + } + } + + /** + * Allow services to join the startup thread until startup is complete + */ + public final void joinStartup() throws InterruptedException { + startupLatch.await(60, TimeUnit.SECONDS); + } + + public static void setupEnvironment(MqttsnOptions options){ + if(options.getLogPattern() != null){ + System.setProperty("java.util.logging.SimpleFormatter.format", "[%1$tc] %4$s %2$s - %5$s %6$s%n"); + } + } + + protected final void messageReceived(IMqttsnContext context, String topicName, int QoS, byte[] payload){ + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("publish received by application [%s], notifying [%s] listeners", topicName, receivedListeners.size())); + } + receivedListeners.stream().forEach(p -> p.receive(context, topicName, QoS, payload)); + } + + protected final void messageSent(IMqttsnContext context, UUID messageId, String topicName, int QoS, byte[] payload){ + if(logger.isLoggable(Level.FINE)) { + logger.log(Level.FINE, String.format("sent confirmed by application [%s], notifying [%s] listeners", topicName, sentListeners.size())); + } + sentListeners.stream().forEach(p -> p.sent(context, messageId, topicName, QoS, payload)); + } + + protected final void messageSendFailure(IMqttsnContext context, UUID messageId, String topicName, int QoS, byte[] payload, int retryCount){ + if(logger.isLoggable(Level.FINE)) { + logger.log(Level.FINE, String.format("message failed sending [%s], notifying [%s] listeners", topicName, sendFailureListeners.size())); + } + sendFailureListeners.stream().forEach(p -> p.sendFailure(context, messageId, topicName, QoS, payload, retryCount)); + } + + public void registerReceivedListener(IMqttsnPublishReceivedListener listener) { + if(listener == null) throw new IllegalArgumentException("cannot register listener"); + if(!receivedListeners.contains(listener)) + receivedListeners.add(listener); + } + + public void registerSentListener(IMqttsnPublishSentListener listener) { + if(listener == null) throw new IllegalArgumentException("cannot register listener"); + if(!sentListeners.contains(listener)) + sentListeners.add(listener); + } + + public void registerConnectionListener(IMqttsnConnectionStateListener listener) { + if(listener == null) throw new IllegalArgumentException("cannot register listener"); + if(!connectionListeners.contains(listener)) + connectionListeners.add(listener); + } + + public void registerPublishFailedListener(IMqttsnPublishFailureListener listener) { + if(listener == null) throw new IllegalArgumentException("cannot register listener"); + if(!sendFailureListeners.contains(listener)) + sendFailureListeners.add(listener); + } + + + /** + * A Disconnect was received from the remote context + * @param context - The context who sent the DISCONNECT + * @return should the local runtime send a DISCONNECT in reponse + */ + public boolean handleRemoteDisconnect(IMqttsnContext context){ + logger.log(Level.INFO, String.format("notified of remote disconnect [%s]", context)); + connectionListeners.stream().forEach(p -> p.notifyRemoteDisconnect(context)); + return true; + } + + /** + * When the runtime reaches a condition from which it cannot recover for the context, + * it will generate a DISCONNECT to send to the context, the exception and context are then + * passed to this method so the application has visibility of them + * @param context - The context whose state encountered the problem thag caused the DISCONNECT + * @param t - the exception that was encountered + * @return was the exception handled, if so, the trace is not thrown up to the transport layer, + * if not, the exception is reported into the transport layer + */ + public boolean handleLocalDisconnect(IMqttsnContext context, Throwable t){ + logger.log(Level.INFO, String.format("notified of local disconnect [%s]", context, t)); + connectionListeners.stream().forEach(p -> p.notifyLocalDisconnect(context, t)); + return true; + } + + /** + * Reported by the transport layer when its (stateful) connection is lost. Invariably + * this will be Socket connections over TCP IP + * @param context - The context whose state encountered the problem thag caused the DISCONNECT + * @param t - the exception that was encountered + * @return was the exception handled + */ + public void handleConnectionLost(IMqttsnContext context, Throwable t){ + logger.log(Level.INFO, String.format("notified of connection lost [%s]", context, t)); + connectionListeners.stream().forEach(p -> p.notifyConnectionLost(context, t)); + } + + /** + * Reported the when a CONNECTION is successfully established + * @param context + */ + public void handleConnected(IMqttsnContext context){ + logger.log(Level.INFO, String.format("notified of new connection [%s]", context)); + connectionListeners.stream().forEach(p -> p.notifyConnected(context)); + } + + /** + * Reported the when a CONNECTION is successfully established + * @param context + */ + public void handleActiveTimeout(IMqttsnContext context){ + logger.log(Level.INFO, String.format("notified of active timeout [%s]", context)); + connectionListeners.stream().forEach(p -> p.notifyActiveTimeout(context)); + } + + /** + * Submit work for the main worker thread group, this could be + * transport operations or confirmations etc. + */ + public Future async(Runnable r){ + return executorService.submit(r); + } + + /** + * @return - The thread group for this runtime + */ + public ThreadGroup getThreadGroup(){ + return threadGroup; + } + + public abstract void close() throws IOException ; + + protected abstract void startupServices(IMqttsnRuntimeRegistry runtime) throws MqttsnException; + + protected abstract void stopServices(IMqttsnRuntimeRegistry runtime) throws MqttsnException; +} \ No newline at end of file diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnRuntimeRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnRuntimeRegistry.java new file mode 100644 index 00000000..b6cd90a5 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnRuntimeRegistry.java @@ -0,0 +1,421 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.model.MqttsnContext; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.net.NetworkContext; +import org.slj.mqtt.sn.net.NetworkAddress; +import org.slj.mqtt.sn.spi.*; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * The base runtime registry provides support for simple fluent construction and encapsulates + * both the controllers and the configuration for a runtime. Each controller has access to the + * registry and uses it to access other parts of the application and config. + * + * During startup, the registry will be validated to ensure all required components are available. + * Extending implementations should provide convenience methods for out-of-box runtimes. + */ +public abstract class AbstractMqttsnRuntimeRegistry implements IMqttsnRuntimeRegistry { + + protected MqttsnOptions options; + + //-- obtained lazily from the codec --// + protected IMqttsnMessageFactory factory; + + protected IMqttsnCodec codec; + protected IMqttsnMessageHandler messageHandler; + protected IMqttsnMessageQueue messageQueue; + protected IMqttsnTransport transport; + protected AbstractMqttsnRuntime runtime; + protected INetworkAddressRegistry networkAddressRegistry; + protected IMqttsnTopicRegistry topicRegistry; + protected IMqttsnSubscriptionRegistry subscriptionRegistry; + protected IMqttsnMessageStateService messageStateService; + protected IMqttsnContextFactory contextFactory; + protected IMqttsnMessageQueueProcessor queueProcessor; + protected IMqttsnQueueProcessorStateService queueProcessorStateCheckService; + protected IMqttsnMessageRegistry messageRegistry; + protected IMqttsnPermissionService permissionService; + protected List trafficListeners; + + public AbstractMqttsnRuntimeRegistry(MqttsnOptions options){ + this.options = options; + } + + @Override + public void init() { + validateOnStartup(); + initNetworkRegistry(); + factory = codec.createMessageFactory(); + } + + protected void initNetworkRegistry(){ + //-- ensure initial definitions exist in the network registry + if(options.getNetworkAddressEntries() != null && !options.getNetworkAddressEntries().isEmpty()){ + Iterator itr = options.getNetworkAddressEntries().keySet().iterator(); + while(itr.hasNext()){ + String key = itr.next(); + NetworkAddress address = options.getNetworkAddressEntries().get(key); + NetworkContext networkContext = new NetworkContext(address); + MqttsnContext sessionContext = new MqttsnContext(key); + networkAddressRegistry.bindContexts(networkContext, sessionContext); + } + } + } + + /** + * Traffic listeners can contributed to the runtime to be notified of any traffic processed by + * the transport layer. Listeners are not able to affect the traffic in transit or the business + * logic executed during the course of the traffic, they are merely observers. + * + * The listeners ~may be notified asynchronously from the application, and thus they cannot be relied + * upon to give an absolute timeline of traffic. + * + * @param trafficListener - The traffic listener instance which will be notified upon traffic being processed. + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withTrafficListener(IMqttsnTrafficListener trafficListener){ + if(trafficListeners == null){ + synchronized (this){ + if(trafficListeners == null){ + trafficListeners = new ArrayList<>(); + } + } + } + trafficListeners.add(trafficListener); + return this; + } + + /** + * NOTE: this is optional + * When contributed, this controller is used by the queue processor to check if the application is in a fit state to + * offload messages to a remote (gateway or client), and is called back by the queue processor to be notified of + * the queue being empty after having been flushed. + * + * @param queueProcessorStateCheckService - The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withQueueProcessorStateCheck(IMqttsnQueueProcessorStateService queueProcessorStateCheckService){ + this.queueProcessorStateCheckService = queueProcessorStateCheckService; + return this; + } + + /** + * The job of the queue processor is to (when requested) interact with a remote contexts' queue, processing + * the next message from the HEAD of the queue, handling any topic registration, session state, marking + * messages inflight and finally returning an indicator as to what should happen when the processing of + * the next message is complete. Upon dealing with the next message, whether successful or not, the processor + * needs to return an indiction; + * + * REMOVE_PROCESS - The queue is empty and the context no longer needs further processing + * BACKOFF_PROCESS - The queue is not empty, come back after a backend to try again. Repeating this return type for the same context + * will yield an exponential backoff + * REPROCESS (continue) - The queue is not empty, (where possible) call me back immediately to process again + * + * @param queueProcessor - The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withQueueProcessor(IMqttsnMessageQueueProcessor queueProcessor){ + this.queueProcessor = queueProcessor; + return this; + } + + /** + * A context factory deals with the initial construction of the context objects which identity + * the remote connection to the application. There are 2 types of context; a {@link NetworkContext} + * and a {@link MqttsnContext}. The network context identifies where (the network location) the identity + * resides and the mqttsn-context identifies who the context is (generally this is the CliendId or GatewayId of + * the connected resource). + * + * A {@link NetworkContext} can exist in isolation without an associated {@link MqttsnContext}, during a CONNECT attempt + * (when the context has yet to be established), or during a failed CONNECTion. An application context cannot exist without + * a network context. + * + * You can provide your own implementation, if you wish to wrap or provide your own extending context implementation + * to wrap custom User objects, for example. + * + * @param contextFactory - The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withContextFactory(IMqttsnContextFactory contextFactory){ + this.contextFactory = contextFactory; + return this; + } + + /** + * The message registry is a normalised view of transiting messages, it context the raw payload of publish operations + * so light weight references to the payload can exist in multiple storage systems without duplication of data. + * For example, when running in gateway mode, the same message my reside in queues for numerous devices which are + * in different connection states. We should not store payloads N times in this case. + * + * The lookup is a simple UUID -> byte[] relationship. It is up to the registry implementation to decide how to store + * this data. + * + * @param messageRegistry The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withMessageRegistry(IMqttsnMessageRegistry messageRegistry){ + this.messageRegistry = messageRegistry; + return this; + } + + /** + * The state service is responsible for sending messages and processing received messages. It maintains state + * and tracks messages in and out and their successful acknowledgement (or not). + * + * The message handling layer will call into the state service with messages it has received, and the queue processor + * will use the state service to dispatch new outbound publish messages. + * + * @param messageStateService The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withMessageStateService(IMqttsnMessageStateService messageStateService){ + this.messageStateService = messageStateService; + return this; + } + + /** + * The subscription registry maintains a list of subscriptions against the remote context. On the gateway this + * is used to determine which clients are subscribed to which topics to enable outbound delivery. In client + * mode it tracks the subscriptions a client presently holds. + * + * @param subscriptionRegistry The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withSubscriptionRegistry(IMqttsnSubscriptionRegistry subscriptionRegistry){ + this.subscriptionRegistry = subscriptionRegistry; + return this; + } + + /** + * The topic registry is responsible for tracking, storing and determining the correct alias + * to use for a given remote context and topic combination. The topic registry will be cleared + * according to session lifecycle rules. + * + * @param topicRegistry The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withTopicRegistry(IMqttsnTopicRegistry topicRegistry){ + this.topicRegistry = topicRegistry; + return this; + } + + /** + * Queue implementation to store messages destined to and from gateways and clients. Queues will be flushed acccording + * to the session semantics defined during CONNECT. + * + * Ideally the queue should be implemented to support FIFO where possible. + * + * @param messageQueue The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withMessageQueue(IMqttsnMessageQueue messageQueue){ + this.messageQueue = messageQueue; + return this; + } + + /** + * A codec contains all the functionality to marshall and unmarshall + * wire traffic in the format specified by the implementation. Further, + * it also provides a message factory instance which allows construction + * of wire messages hiding the underlying transport format. This allows versioned + * protocol support. + * + * @param codec The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withCodec(IMqttsnCodec codec){ + this.codec = codec; + return this; + } + + /** + * The message handler is delegated to by the transport layer and its job is to process + * inbound messages and marshall into other controllers to manage state lifecycle, authentication, permission + * etc. It is directly responsible for creating response messages and sending them back to the transport layer. + * + * @param handler The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withMessageHandler(IMqttsnMessageHandler handler){ + this.messageHandler = handler; + return this; + } + + /** + * The transport layer is responsible for managing the receiving and sending of messages over some connection. + * No session is assumed by the application and the connection is considered stateless at this point. + * It is envisaged implementations will include UDP (with and without DTLS), TCP-IP (with and without TLS), + * BLE and ZigBee. + * + * Please refer to {@link org.slj.mqtt.sn.impl.AbstractMqttsnTransport} and sub-class your own implementations + * or choose an existing implementation out of the box. + * + * @see {@link org.slj.mqtt.sn.net.MqttsnUdpTransport} for an example of an out of the box implementation. + * + * @param transport The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withTransport(IMqttsnTransport transport){ + this.transport = transport; + return this; + } + + /** + * The network registry maintains a list of known network contexts against a remote address ({@link NetworkAddress}). + * It exposes functionality to wait for discovered contexts as well as returning a list of valid broadcast addresses. + * + * @param networkAddressRegistry The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withNetworkAddressRegistry(INetworkAddressRegistry networkAddressRegistry){ + this.networkAddressRegistry = networkAddressRegistry; + return this; + } + + /** + * Optional - when installed it will be consulted to determine whether a remote context can perform certain + * operations; + * + * CONNECT with the given clientId + * SUBSCRIBE to a given topicPath + * Granted Maximum Subscription Levels + * Eligibility to publish to a given path & size + * + * @param permissionService The instance + * @return This runtime registry + */ + public AbstractMqttsnRuntimeRegistry withPermissionService(IMqttsnPermissionService permissionService){ + this.permissionService = permissionService; + return this; + } + + @Override + public MqttsnOptions getOptions() { + return options; + } + + @Override + public INetworkAddressRegistry getNetworkRegistry() { + return networkAddressRegistry; + } + + @Override + public void setRuntime(AbstractMqttsnRuntime runtime) { + this.runtime = runtime; + } + + @Override + public AbstractMqttsnRuntime getRuntime() { + return runtime; + } + + @Override + public IMqttsnPermissionService getPermissionService() { + return permissionService; + } + + public IMqttsnQueueProcessorStateService getQueueProcessorStateCheckService() { + return queueProcessorStateCheckService; + } + + @Override + public IMqttsnCodec getCodec() { + return codec; + } + + @Override + public IMqttsnMessageHandler getMessageHandler() { + return messageHandler; + } + + @Override + public IMqttsnTransport getTransport() { + return transport; + } + + @Override + public IMqttsnMessageFactory getMessageFactory(){ + return factory; + } + + @Override + public IMqttsnMessageQueue getMessageQueue() { + return messageQueue; + } + + @Override + public IMqttsnTopicRegistry getTopicRegistry() { + return topicRegistry; + } + + @Override + public IMqttsnSubscriptionRegistry getSubscriptionRegistry() { + return subscriptionRegistry; + } + + @Override + public IMqttsnMessageStateService getMessageStateService() { + return messageStateService; + } + + @Override + public IMqttsnMessageRegistry getMessageRegistry(){ + return messageRegistry; + } + + @Override + public List getTrafficListeners() { + return trafficListeners; + } + + @Override + public IMqttsnContextFactory getContextFactory() { + return contextFactory; + } + + @Override + public IMqttsnMessageQueueProcessor getQueueProcessor() { + return queueProcessor; + } + + protected void validateOnStartup() throws MqttsnRuntimeException { + if(networkAddressRegistry == null) throw new MqttsnRuntimeException("network-registry must be bound for valid runtime"); + if(messageStateService == null) throw new MqttsnRuntimeException("message state service must be bound for valid runtime"); + if(transport == null) throw new MqttsnRuntimeException("transport must be bound for valid runtime"); + if(topicRegistry == null) throw new MqttsnRuntimeException("topic registry must be bound for valid runtime"); + if(codec == null) throw new MqttsnRuntimeException("codec must be bound for valid runtime"); + if(messageHandler == null) throw new MqttsnRuntimeException("message handler must be bound for valid runtime"); + if(messageQueue == null) throw new MqttsnRuntimeException("message queue must be bound for valid runtime"); + if(contextFactory == null) throw new MqttsnRuntimeException("context factory must be bound for valid runtime"); + if(queueProcessor == null) throw new MqttsnRuntimeException("queue processor must be bound for valid runtime"); + if(messageRegistry == null) throw new MqttsnRuntimeException("message registry must be bound for valid runtime"); + } +} \ No newline at end of file diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnTransport.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnTransport.java new file mode 100644 index 00000000..2210a40a --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnTransport.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +/** + * The abstract transport implementation provides many of the requisite behaviours required of the + * transport layer, including message marshalling, thread handling (handoff), authority checking and traffic listener notification. + * You should sub-class this base as a starting point for you implementations. + */ +public abstract class AbstractMqttsnTransport + extends MqttsnService implements IMqttsnTransport { + + public void connectionLost(INetworkContext context, Throwable t){ + if(registry != null && context != null){ + registry.getRuntime().handleConnectionLost(registry.getNetworkRegistry().getSessionContext(context), t); + } + } + + public boolean restartOnLoss(){ + return true; + } + + @Override + public void receiveFromTransport(INetworkContext context, ByteBuffer buffer) { + if(registry.getOptions().isThreadHandoffFromTransport()){ + getRegistry().getRuntime().async( + () -> receiveFromTransportInternal(context, buffer)); + } else { + receiveFromTransportInternal(context, buffer); + } + } + + protected void receiveFromTransportInternal(INetworkContext networkContext, ByteBuffer buffer) { + try { + if(!registry.getMessageHandler().running()){ + return; + } + byte[] data = drain(buffer); + + if(data.length > registry.getOptions().getMaxProtocolMessageSize()){ + logger.log(Level.SEVERE, String.format("receiving [%s] bytes - max allowed message size [%s] - error", + data.length, registry.getOptions().getMaxProtocolMessageSize())); + throw new MqttsnRuntimeException("received message was larger than allowed max"); + } + + if(registry.getOptions().isWireLoggingEnabled()){ + logger.log(Level.INFO, String.format("receiving [%s] ", + MqttsnWireUtils.toBinary(data))); + } + + IMqttsnMessage message = getRegistry().getCodec().decode(data); + + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("receiving [%s] bytes (%s) from [%s] on thread [%s]", + data.length, message.getMessageName(), networkContext, Thread.currentThread().getName())); + } + + boolean authd = true; + //-- if we detect an inbound id packet, we should authorise the context every time (even if the impl just reuses existing auth) + if(message instanceof IMqttsnIdentificationPacket){ + if(!registry.getNetworkRegistry().hasBoundSessionContext(networkContext)){ + authd = registry.getMessageHandler().authorizeContext(networkContext, + ((IMqttsnIdentificationPacket)message).getClientId()); + } + } + else { + //-- sort the case where publish -1 can be recieved without an authd context from the + //-- network address alone + if(!registry.getNetworkRegistry().hasBoundSessionContext(networkContext) && + registry.getCodec().isPublish(message) && + registry.getCodec().getData(message).getQos() == -1){ + logger.log(Level.INFO, String.format("detected non authorised publish -1, apply for temporary auth from network context [%s]", networkContext)); + authd = registry.getMessageHandler().temporaryAuthorizeContext(networkContext); + } + } + + + if(authd && registry.getNetworkRegistry().hasBoundSessionContext(networkContext)){ + notifyTrafficReceived(networkContext, data, message); + registry.getMessageHandler().receiveMessage(registry.getNetworkRegistry().getSessionContext(networkContext), message); + } else { + logger.log(Level.WARNING, "auth could not be established, send disconnect that is not processed by application"); + writeToTransportInternal(networkContext, registry.getMessageFactory().createDisconnect(), false); + } + } catch(Throwable t){ + logger.log(Level.SEVERE, "unhandled error;", t); + } + } + + @Override + public void writeToTransport(INetworkContext context, IMqttsnMessage message) { + if(registry.getOptions().isThreadHandoffFromTransport()){ + getRegistry().getRuntime().async( + () -> writeToTransportInternal(context, message, true)); + } else { + writeToTransportInternal(context, message, true); + } + } + + protected void writeToTransportInternal(INetworkContext context, IMqttsnMessage message, boolean notifyListeners){ + try { + byte[] data = registry.getCodec().encode(message); + + if(data.length > registry.getOptions().getMaxProtocolMessageSize()){ + logger.log(Level.SEVERE, String.format("cannot send [%s] bytes - max allowed message size [%s]", + data.length, registry.getOptions().getMaxProtocolMessageSize())); + throw new MqttsnRuntimeException("cannot send messages larger than allowed max"); + } + + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("writing [%s] bytes (%s) to [%s] on thread [%s]", + data.length, message.getMessageName(), context, Thread.currentThread().getName())); + } + + if(registry.getOptions().isWireLoggingEnabled()){ + logger.log(Level.INFO, String.format("writing [%s] ", + MqttsnWireUtils.toBinary(data))); + } + + writeToTransport(context, ByteBuffer.wrap(data, 0 , data.length)); + if(notifyListeners) notifyTrafficSent(context, data, message); + } catch(Throwable e){ + logger.log(Level.SEVERE, String.format("[%s] transport layer errord sending buffer", context), e); + } + } + + private void notifyTrafficReceived(final INetworkContext context, byte[] data, IMqttsnMessage message) { + List list = getRegistry().getTrafficListeners(); + if(list != null && !list.isEmpty()){ + list.stream().forEach(l -> l.trafficReceived(context, data, message)); + } + } + + private void notifyTrafficSent(final INetworkContext context, byte[] data, IMqttsnMessage message) { + List list = getRegistry().getTrafficListeners(); + if(list != null && !list.isEmpty()){ + list.stream().forEach(l -> l.trafficSent(context, data, message)); + } + } + + protected abstract void writeToTransport(INetworkContext context, ByteBuffer data) throws MqttsnException ; + + protected static ByteBuffer wrap(byte[] arr){ + return wrap(arr, arr.length); + } + + protected static ByteBuffer wrap(byte[] arr, int length){ + return ByteBuffer.wrap(arr, 0 , length); + } + + protected static byte[] drain(ByteBuffer buffer){ + byte[] arr = new byte[buffer.remaining()]; + buffer.get(arr, 0, arr.length); + return arr; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnUdpTransport.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnUdpTransport.java new file mode 100644 index 00000000..66e6070a --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractMqttsnUdpTransport.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.net.MqttsnUdpOptions; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.nio.ByteBuffer; + +public abstract class AbstractMqttsnUdpTransport + extends AbstractMqttsnTransport { + + protected final MqttsnUdpOptions options; + + public AbstractMqttsnUdpTransport(MqttsnUdpOptions options){ + this.options = options; + } + + @Override + public synchronized void start(U runtime) throws MqttsnException { + try { + super.start(runtime); + bind(); + } catch(Exception e){ + throw new MqttsnException(e); + } + } + + public MqttsnUdpOptions getUdpOptions(){ + return options; + } + + public boolean restartOnLoss(){ + return false; + } + + protected abstract void bind() throws Exception; + + public abstract void writeToTransport(INetworkContext context, ByteBuffer buffer) throws MqttsnException ; +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractRationalTopicService.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractRationalTopicService.java new file mode 100644 index 00000000..315139e1 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractRationalTopicService.java @@ -0,0 +1,21 @@ +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.MqttsnService; + +public class AbstractRationalTopicService + extends MqttsnService { + + /** + * Method called before any interactions into or out of the registry to allow for + * hooking to manipulate topic formats + * @param context + * @param topicName + * @return + */ + protected String rationalizeTopic(IMqttsnContext context, String topicName) throws MqttsnException { + return topicName; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractSubscriptionRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractSubscriptionRegistry.java new file mode 100644 index 00000000..0424444d --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractSubscriptionRegistry.java @@ -0,0 +1,61 @@ +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.Subscription; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.IMqttsnSubscriptionRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.MqttsnService; +import org.slj.mqtt.sn.utils.TopicPath; + +import java.util.Iterator; +import java.util.Set; + +public abstract class AbstractSubscriptionRegistry + extends AbstractRationalTopicService + implements IMqttsnSubscriptionRegistry { + + @Override + public boolean subscribe(IMqttsnContext context, String topicPath, int QoS) throws MqttsnException { + TopicPath path = new TopicPath(rationalizeTopic(context, topicPath)); + return addSubscription(context, new Subscription(path, QoS)); + } + + @Override + public boolean unsubscribe(IMqttsnContext context, String topicPath) throws MqttsnException { + Set paths = readSubscriptions(context); + TopicPath path = new TopicPath(rationalizeTopic(context, topicPath)); + Subscription sub = new Subscription(path); + if(paths.contains(sub)){ + return removeSubscription(context, sub); + } + return false; + } + + @Override + public int getQos(IMqttsnContext context, String topicPath) throws MqttsnException { + Set paths = readSubscriptions(context); + if(paths != null && !paths.isEmpty()) { + Iterator pathItr = paths.iterator(); + client: + while (pathItr.hasNext()) { + try { + Subscription sub = pathItr.next(); + TopicPath path = sub.getTopicPath(); + if (path.matches(rationalizeTopic(context, topicPath))) { + return sub.getQoS(); + } + } catch (Exception e) { + throw new MqttsnException(e); + } + } + } + throw new MqttsnException("no matching subscription found for client"); + } + + public abstract Set readSubscriptions(IMqttsnContext context) throws MqttsnException ; + + protected abstract boolean addSubscription(IMqttsnContext context, Subscription subscription) throws MqttsnException ; + + protected abstract boolean removeSubscription(IMqttsnContext context, Subscription subscription) throws MqttsnException ; +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractTopicRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractTopicRegistry.java new file mode 100644 index 00000000..a5c777c5 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/AbstractTopicRegistry.java @@ -0,0 +1,214 @@ +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.TopicInfo; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.utils.MqttsnUtils; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; + +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.logging.Level; + +public abstract class AbstractTopicRegistry + extends AbstractRationalTopicService + implements IMqttsnTopicRegistry { + + @Override + public TopicInfo register(IMqttsnContext context, String topicPath) throws MqttsnException { + Map map = getRegistrationsInternal(context, false); + if(map.size() >= registry.getOptions().getMaxTopicsInRegistry()){ + logger.log(Level.WARNING, String.format("max number of registered topics reached for client [%s] >= [%s]", context, map.size())); + throw new MqttsnException("max number of registered topics reached for client"); + } + synchronized (map){ + int alias = 0; + if(map.containsKey(topicPath)){ + alias = map.get(topicPath); + addOrUpdateRegistration(context, rationalizeTopic(context, topicPath), alias); + } else { + alias = MqttsnUtils.getNextLeaseId(map.values(), Math.max(1, registry.getOptions().getAliasStartAt())); + addOrUpdateRegistration(context, rationalizeTopic(context, topicPath), alias); + } + TopicInfo info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.NORMAL, alias); + return info; + } + } + + @Override + public void register(IMqttsnContext context, String topicPath, int topicAlias) throws MqttsnException { + + logger.log(Level.INFO, String.format("registering topic path [%s] -> [%s]", topicPath, topicAlias)); + Map map = getRegistrationsInternal(context, false); + if(map.containsKey(topicPath)){ + //update existing + addOrUpdateRegistration(context, rationalizeTopic(context, topicPath), topicAlias); + } else { + if(map.size() >= registry.getOptions().getMaxTopicsInRegistry()){ + logger.log(Level.WARNING, String.format("max number of registered topics reached for client [%s] >= [%s]", context, map.size())); + throw new MqttsnException("max number of registered topics reached for client"); + } + addOrUpdateRegistration(context, rationalizeTopic(context, topicPath), topicAlias); + } + } + + @Override + public boolean registered(IMqttsnContext context, String topicPath) throws MqttsnException { + Map map = getRegistrationsInternal(context, false); + return map.containsKey(rationalizeTopic(context, topicPath)); + } + + @Override + public String topicPath(IMqttsnContext context, TopicInfo topicInfo, boolean considerContext) throws MqttsnException { + String topicPath = null; + switch (topicInfo.getType()){ + case SHORT: + topicPath = topicInfo.getTopicPath(); + break; + case PREDEFINED: + topicPath = lookupPredefined(context, topicInfo.getTopicId()); + break; + case NORMAL: + if(considerContext){ + if(context == null) throw new MqttsnExpectationFailedException(" context cannot be considered"); + topicPath = lookupRegistered(context, topicInfo.getTopicId()); + } + break; + default: + case RESERVED: + break; + } + + if(topicPath == null) { + logger.log(Level.WARNING, String.format("unable to find matching topicPath in system for [%s] -> [%s], available was [%s]", + topicInfo, context, Objects.toString(getRegistrationsInternal(context, false)))); + throw new MqttsnExpectationFailedException("unable to find matching topicPath in system"); + } + return rationalizeTopic(context, topicPath); + } + + @Override + public String lookupRegistered(IMqttsnContext context, int topicAlias) throws MqttsnException { + Map map = getRegistrationsInternal(context, false); + synchronized (map){ + Iterator itr = map.keySet().iterator(); + while(itr.hasNext()){ + String topicPath = itr.next(); + Integer i = map.get(topicPath); + if(i != null && i.intValue() == topicAlias) + return rationalizeTopic(context, topicPath); + } + } + return null; + } + + @Override + public Integer lookupRegistered(IMqttsnContext context, String topicPath, boolean confirmedOnly) throws MqttsnException { + Map map = getRegistrationsInternal(context, confirmedOnly); + return map.get(rationalizeTopic(context, topicPath)); + } + + @Override + public Integer lookupRegistered(IMqttsnContext context, String topicPath) throws MqttsnException { + Map map = getRegistrationsInternal(context, false); + return map.get(rationalizeTopic(context, topicPath)); + } + + @Override + public Integer lookupPredefined(IMqttsnContext context, String topicPath) throws MqttsnException { + Map predefinedMap = getPredefinedTopicsForString(context); + return predefinedMap.get(rationalizeTopic(context, topicPath)); + } + + @Override + public String lookupPredefined(IMqttsnContext context, int topicAlias) throws MqttsnException { + Map predefinedMap = getPredefinedTopicsForInteger(context); + synchronized (predefinedMap){ + Iterator itr = predefinedMap.keySet().iterator(); + while(itr.hasNext()){ + String topicPath = itr.next(); + Integer i = predefinedMap.get(topicPath); + if(i != null && i.intValue() == topicAlias) + return rationalizeTopic(context, topicPath); + } + } + return null; + } + + @Override + public TopicInfo lookup(IMqttsnContext context, String topicPath, boolean confirmedOnly) throws MqttsnException { + topicPath = rationalizeTopic(context, topicPath); + + //-- check normal first + TopicInfo info = null; + if(registered(context, topicPath)){ + Integer topicAlias = lookupRegistered(context, topicPath, confirmedOnly); + if(topicAlias != null){ + info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.NORMAL, topicAlias); + } + } + + //-- check predefined if nothing in session registry + if(info == null){ + Integer topicAlias = lookupPredefined(context, topicPath); + if(topicAlias != null){ + info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.PREDEFINED, topicAlias); + } + } + + //-- if topicPath < 2 chars + if(info == null){ + if(topicPath.length() <= 2){ + info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.SHORT, topicPath); + } + } + + logger.log(Level.INFO, String.format("topic-registry lookup for [%s] => [%s] found [%s]", context, topicPath, info)); + return info; + } + + @Override + public TopicInfo lookup(IMqttsnContext context, String topicPath) throws MqttsnException { + return lookup(context, topicPath, false); + } + + @Override + public TopicInfo normalize(byte topicIdType, byte[] topicData, boolean normalAsLong) throws MqttsnException { + TopicInfo info = null; + switch (topicIdType){ + case MqttsnConstants.TOPIC_SHORT: + if(topicData.length != 2){ + throw new MqttsnExpectationFailedException("short topics must be exactly 2 bytes"); + } + info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.SHORT, new String(topicData, MqttsnConstants.CHARSET)); + break; + case MqttsnConstants.TOPIC_NORMAL: + if(normalAsLong){ //-- in the case of a subscribe, the normal actually means the full topic name + info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.NORMAL, new String(topicData, MqttsnConstants.CHARSET)); + } else { + if(topicData.length != 2){ + throw new MqttsnExpectationFailedException("normal topic aliases must be exactly 2 bytes"); + } + info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.NORMAL, MqttsnWireUtils.read16bit(topicData[0],topicData[1])); + } + break; + case MqttsnConstants.TOPIC_PREDEFINED: + if(topicData.length != 2){ + throw new MqttsnExpectationFailedException("predefined topic aliases must be exactly 2 bytes"); + } + info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.PREDEFINED, MqttsnWireUtils.read16bit(topicData[0],topicData[1])); + break; + } + return info; + } + + protected abstract boolean addOrUpdateRegistration(IMqttsnContext context, String topicPath, int alias) throws MqttsnException; + + protected abstract Map getRegistrationsInternal(IMqttsnContext context, boolean confirmedOnly) throws MqttsnException; + + protected abstract Map getPredefinedTopicsForInteger(IMqttsnContext context) throws MqttsnException; + + protected abstract Map getPredefinedTopicsForString(IMqttsnContext context) throws MqttsnException; +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/MqttsnContextFactory.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/MqttsnContextFactory.java new file mode 100644 index 00000000..f6254b6a --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/MqttsnContextFactory.java @@ -0,0 +1,43 @@ +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.model.MqttsnContext; +import org.slj.mqtt.sn.net.NetworkAddress; +import org.slj.mqtt.sn.net.NetworkContext; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.wire.version1_2.payload.MqttsnConnect; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public class MqttsnContextFactory + extends MqttsnService implements IMqttsnContextFactory { + + protected static Logger logger = Logger.getLogger(MqttsnContextFactory.class.getName()); + + @Override + public INetworkContext createInitialNetworkContext(NetworkAddress address) throws MqttsnException { + + logger.log(Level.INFO, + String.format("create new network context for [%s]", address)); + NetworkContext context = new NetworkContext(address); + return context; + } + + @Override + public IMqttsnContext createInitialApplicationContext(INetworkContext networkContext, String clientId) throws MqttsnSecurityException { + + logger.log(Level.INFO, String.format("create new mqtt-sn context for [%s]", clientId)); + MqttsnContext context = new MqttsnContext(clientId); + return context; + } + + @Override + public IMqttsnContext createTemporaryApplicationContext(INetworkContext networkContext) throws MqttsnSecurityException { + + logger.log(Level.INFO, String.format("create temporary mqtt-sn context for [%s]", networkContext)); + MqttsnContext context = new MqttsnContext(null); + return context; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/MqttsnMessageQueueProcessor.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/MqttsnMessageQueueProcessor.java new file mode 100644 index 00000000..24ce7a34 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/MqttsnMessageQueueProcessor.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl; + +import org.slj.mqtt.sn.model.*; +import org.slj.mqtt.sn.spi.*; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public class MqttsnMessageQueueProcessor + extends MqttsnService implements IMqttsnMessageQueueProcessor{ + + static Logger logger = Logger.getLogger(MqttsnMessageQueueProcessor.class.getName()); + + protected boolean clientMode; + + public MqttsnMessageQueueProcessor(boolean clientMode) { + this.clientMode = clientMode; + } + + public RESULT process(IMqttsnContext context) throws MqttsnException { + + IMqttsnQueueProcessorStateService stateCheckService = getRegistry().getQueueProcessorStateCheckService(); + + synchronized (context){ + //-- if the queue is empty, then something will happen to retrigger this process, ie. message in or out + //-- so safe to remove + int count = registry.getMessageQueue().size(context); + + if(logger.isLoggable(Level.INFO)){ + logger.log(Level.INFO, + String.format("processing queue size [%s] on thread [%s] in client-mode [%s] for [%s]", count, Thread.currentThread().getName(), clientMode, context)); + } + + if(count == 0){ + if(stateCheckService != null){ + logger.log(Level.INFO, + String.format("notifying state service of queue empty thread [%s] for [%s]", Thread.currentThread().getName(), context)); + //-- this checks on the state of any session and if its AWAKE will lead to a PINGRESP being sent + stateCheckService.queueEmpty(context); + } + return RESULT.REMOVE_PROCESS; + } + + //-- this call checks session state to ensure the client has an active or awake session + if(stateCheckService != null && !stateCheckService.canReceive(context)) { + return RESULT.REMOVE_PROCESS; + } + + //-- this checks the inflight if its > 0 we cannot send + if(!registry.getMessageStateService().canSend(context)) { + logger.log(Level.INFO, String.format("state service determined cant send at the moment [%s]", context)); + return RESULT.BACKOFF_PROCESS; + } + + QueuedPublishMessage queuedMessage = registry.getMessageQueue().peek(context); + if(queuedMessage != null){ + return processNextMessage(context); + } else { + return clientMode ? RESULT.REPROCESS : RESULT.REMOVE_PROCESS; + } + } + } + + /** + * Uses the next message and establshes a register if no support topic alias's exist + */ + protected RESULT processNextMessage(IMqttsnContext context) throws MqttsnException { + + QueuedPublishMessage queuedMessage = registry.getMessageQueue().peek(context); + String topicPath = queuedMessage.getTopicPath(); + TopicInfo info = registry.getTopicRegistry().lookup(context, topicPath, true); + if(info == null){ + logger.log(Level.INFO, String.format("need to register for delivery to [%s] on topic [%s]", context, topicPath)); + if(!clientMode){ + //-- only the server hands out alias's + info = registry.getTopicRegistry().register(context, topicPath); + } + IMqttsnMessage register = registry.getMessageFactory().createRegister(info != null ? info.getTopicId() : 0, topicPath); + try { + MqttsnWaitToken token = registry.getMessageStateService().sendMessage(context, register); + if(clientMode){ + if(token != null){ + registry.getMessageStateService().waitForCompletion(context, token); + } + } + } catch(MqttsnExpectationFailedException e){ + logger.log(Level.WARNING, String.format("unable to send message, try again later"), e); + } + //-- with a register we should come back when the registration is complete and attempt delivery + return RESULT.REPROCESS; + } else { + //-- only deque when we have confirmed we can deliver + return dequeAndPublishNextMessage(context, info); + } + } + + protected RESULT dequeAndPublishNextMessage(IMqttsnContext context, TopicInfo info) throws MqttsnException { + QueuedPublishMessage queuedMessage = registry.getMessageQueue().pop(context); + if (queuedMessage != null) { + queuedMessage.incrementRetry(); + //-- let the reaper check on delivery + try { + MqttsnWaitToken token = registry.getMessageStateService().sendMessage(context, info, queuedMessage); + if (clientMode) { + if(token != null){ + registry.getMessageStateService().waitForCompletion(context, token); + if(token.isError()){ + //error in delivery + throw new MqttsnException("message was not confirmed or confirmed with error"); + } + } + } + + RESULT res = ((registry.getMessageQueue().size(context) > 0) || + queuedMessage.getGrantedQoS() == 0) ? RESULT.REPROCESS : RESULT.REMOVE_PROCESS; + logger.log(Level.INFO, String.format("sending complete returning [%s] for [%s]", res, context)); + return res; + } catch (MqttsnException e) { + + //-- error so back off a little + return RESULT.BACKOFF_PROCESS; + } + } else { + //-- no more messages + return RESULT.REMOVE_PROCESS; + } + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageQueue.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageQueue.java new file mode 100644 index 00000000..36fe54fc --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageQueue.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl.ram; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.MqttsnQueueAcceptException; +import org.slj.mqtt.sn.model.MqttsnWaitToken; +import org.slj.mqtt.sn.model.QueuedPublishMessage; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.utils.MqttsnUtils; + +import java.util.*; +import java.util.logging.Level; + +public class MqttsnInMemoryMessageQueue + extends MqttsnService implements IMqttsnMessageQueue { + + protected Map> queues; + + @Override + public synchronized void start(T runtime) throws MqttsnException { + queues = Collections.synchronizedMap(new HashMap<>()); + super.start(runtime); + } + + @Override + public int size(IMqttsnContext context) throws MqttsnException { + if (queues.containsKey(context)) { + Queue queue = getQueue(context); + return queue.size(); + } + return 0; + } + + @Override + public MqttsnWaitToken offer(IMqttsnContext context, QueuedPublishMessage message) + throws MqttsnException, MqttsnQueueAcceptException { + Queue queue = getQueue(context); + if(size(context) >= getMaxQueueSize()){ + logger.log(Level.WARNING, String.format("max queue size reached for client [%s] >= [%s]", context, queue.size())); + throw new MqttsnQueueAcceptException("max queue size reached for client"); + } + boolean b; + synchronized (queue){ + b = queue.offer(message); + } + + int size = size(context); + + logger.log(MqttsnUtils.percentOf(size, getMaxQueueSize()) > 80 ? Level.WARNING : Level.FINE, String.format("offered message to queue [%s] for [%s], queue size is [%s]", b, context, queue.size())); + MqttsnWaitToken token = MqttsnWaitToken.from(message); + message.setToken(token); + + if(registry.getMessageStateService() != null) + registry.getMessageStateService().scheduleFlush(context); + + return token; + } + + @Override + public void clear(IMqttsnContext context) throws MqttsnException { + if (queues.containsKey(context)) { + Queue queue = getQueue(context); + synchronized (queue){ + queue.clear(); + } + queues.remove(context); + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("clearing queue for [%s]", context)); + } + } + } + + @Override + public void clearAll() throws MqttsnException { + queues.clear(); + } + + @Override + public Iterator listContexts() throws MqttsnException { + synchronized (queues){ + return new ArrayList<>(queues.keySet()).iterator(); + } + } + + @Override + public QueuedPublishMessage pop(IMqttsnContext context) throws MqttsnException { + Queue queue = getQueue(context); + synchronized (queue){ + QueuedPublishMessage p = queue.poll(); + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("poll form queue for [%s], queue size is [%s]", context, queue.size())); + } + return p; + } + } + + @Override + public QueuedPublishMessage peek(IMqttsnContext context) throws MqttsnException { + Queue queue = getQueue(context); + synchronized (queue){ + return queue.peek(); + } + } + + protected Queue getQueue(IMqttsnContext context){ + Queue queue = queues.get(context); + if(queue == null){ + synchronized (this){ + if((queue = queues.get(context)) == null){ + //-- queued message uses date for natural sort + queue = new PriorityQueue<>(); + queues.put(context, queue); + } + } + } + return queue; + } + + protected int getMaxQueueSize() { + return registry.getOptions().getMaxMessagesInQueue(); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageRegistry.java new file mode 100644 index 00000000..0d3635e0 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageRegistry.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl.ram; + +import org.slj.mqtt.sn.impl.AbstractMqttsnMessageRegistry; +import org.slj.mqtt.sn.impl.AbstractTopicRegistry; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.util.*; +import java.util.logging.Level; + +public class MqttsnInMemoryMessageRegistry + extends AbstractMqttsnMessageRegistry { + + protected Map messageLookup; + + @Override + public synchronized void start(T runtime) throws MqttsnException { + messageLookup = Collections.synchronizedMap(new HashMap<>()); + super.start(runtime); + } + + @Override + protected boolean remove(UUID messageId) throws MqttsnException { + return messageLookup.remove(messageId) != null; + } + + @Override + protected UUID storeInternal(MessageImpl message) throws MqttsnException { + messageLookup.put(message.getMessageId(), message); + return message.getMessageId(); + } + + @Override + protected MessageImpl readInternal(UUID messageId) throws MqttsnException { + return messageLookup.get(messageId); + } + + @Override + public void clearAll() throws MqttsnException { + messageLookup.clear(); + } + + @Override + public void tidy() throws MqttsnException { + Date d = new Date(); + synchronized (messageLookup){ + Iterator itr = messageLookup.keySet().iterator(); + while(itr.hasNext()){ + UUID id = itr.next(); + MessageImpl m = messageLookup.get(id); + if(m.getExpires() != null && m.getExpires().before(d)){ + logger.log(Level.INFO, String.format("expiring message [%s]", id)); + itr.remove(); + } + } + } + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageStateService.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageStateService.java new file mode 100644 index 00000000..441d5efe --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryMessageStateService.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl.ram; + +import org.slj.mqtt.sn.impl.AbstractMqttsnMessageStateService; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.InflightMessage; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.util.*; +import java.util.logging.Level; + +public class MqttsnInMemoryMessageStateService + extends AbstractMqttsnMessageStateService { + + protected Map> inflightMessages; + + public MqttsnInMemoryMessageStateService(boolean clientMode) { + super(clientMode); + } + + @Override + public synchronized void start(T runtime) throws MqttsnException { + inflightMessages = Collections.synchronizedMap(new HashMap()); + super.start(runtime); + } + + @Override + protected long doWork() { + long nextWork = super.doWork(); + synchronized (inflightMessages) { + Iterator itr = inflightMessages.keySet().iterator(); + while (itr.hasNext()) { + try { + IMqttsnContext context = itr.next(); + clearInflightInternal(context, System.currentTimeMillis()); + } catch(MqttsnException e){ + logger.log(Level.WARNING, "error occurred during inflight eviction run;", e); + } + } + } + return nextWork; + } + + @Override + public void clear(IMqttsnContext context) { + inflightMessages.remove(context); + } + + @Override + public void clearAll() { + inflightMessages.clear(); + } + + @Override + public InflightMessage removeInflight(IMqttsnContext context, int msgId) throws MqttsnException { + Map map = getInflightMessages(context); + return map.remove(msgId); + } + + @Override + protected void addInflightMessage(IMqttsnContext context, Integer messageId, InflightMessage message) throws MqttsnException { + Map map = getInflightMessages(context); + synchronized (map){ + map.put(messageId, message); + } + } + + @Override + protected InflightMessage getInflightMessage(IMqttsnContext context, Integer messageId) throws MqttsnException { + return getInflightMessages(context).get(messageId); + } + + @Override + protected boolean inflightExists(IMqttsnContext context, Integer messageId) throws MqttsnException { + return getInflightMessages(context).containsKey(messageId); + } + + @Override + protected Map getInflightMessages(IMqttsnContext context) { + Map map = inflightMessages.get(context); + if(map == null){ + synchronized (this){ + if((map = inflightMessages.get(context)) == null){ + map = new HashMap<>(); + inflightMessages.put(context, map); + } + } + } + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("inflight for [%s] is [%s]", context, Objects.toString(map))); + } + return map; + } + + @Override + protected String getDaemonName() { + return "message-state"; + } +} \ No newline at end of file diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemorySubscriptionRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemorySubscriptionRegistry.java new file mode 100644 index 00000000..b2c20070 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemorySubscriptionRegistry.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl.ram; + +import org.slj.mqtt.sn.impl.AbstractSubscriptionRegistry; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.model.Subscription; +import org.slj.mqtt.sn.spi.*; +import org.slj.mqtt.sn.utils.TopicPath; + +import java.util.*; + +public class MqttsnInMemorySubscriptionRegistry + extends AbstractSubscriptionRegistry { + + protected Map> subscriptionsLookups; + + @Override + public synchronized void start(T runtime) throws MqttsnException { + subscriptionsLookups = Collections.synchronizedMap(new HashMap()); + super.start(runtime); + } + + @Override + public List matches(String topicPath) throws MqttsnException { + List matchingClients = new ArrayList<>(); + synchronized (subscriptionsLookups){ + Iterator clientItr = subscriptionsLookups.keySet().iterator(); + while(clientItr.hasNext()){ + IMqttsnContext client = clientItr.next(); + Set paths = subscriptionsLookups.get(client); + if(paths != null && !paths.isEmpty()){ + Iterator pathItr = paths.iterator(); + client : while(pathItr.hasNext()) { + try { + Subscription sub = pathItr.next(); + TopicPath path = sub.getTopicPath(); + if(path.matches(topicPath)){ + matchingClients.add(client); + break client; + } + } catch(Exception e){ + throw new MqttsnException(e); + } + } + } + } + } + return matchingClients; + } + + @Override + public Set readSubscriptions(IMqttsnContext context){ + Set set = subscriptionsLookups.get(context); + if(set == null){ + synchronized (this){ + if((set = subscriptionsLookups.get(context)) == null){ + set = new HashSet<>(); + subscriptionsLookups.put(context, set); + } + } + } + return set; + } + + @Override + protected boolean addSubscription(IMqttsnContext context, Subscription subscription) throws MqttsnException { + Set set = readSubscriptions(context); + return set.add(subscription); + } + + @Override + protected boolean removeSubscription(IMqttsnContext context, Subscription subscription) throws MqttsnException { + Set set = readSubscriptions(context); + return set.remove(subscription); + } + + @Override + public void clear(IMqttsnContext context) throws MqttsnException { + subscriptionsLookups.remove(context); + } + + @Override + public void clearAll() throws MqttsnException { + subscriptionsLookups.clear(); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryTopicRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryTopicRegistry.java new file mode 100644 index 00000000..fa1c4a9e --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/impl/ram/MqttsnInMemoryTopicRegistry.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.impl.ram; + +import org.slj.mqtt.sn.impl.AbstractTopicRegistry; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.MqttsnContext; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.MqttsnExpectationFailedException; + +import java.util.*; +import java.util.logging.Level; + +public class MqttsnInMemoryTopicRegistry + extends AbstractTopicRegistry { + + protected Map> topicLookups; + + @Override + public synchronized void start(T runtime) throws MqttsnException { + topicLookups = Collections.synchronizedMap(new HashMap<>()); + super.start(runtime); + } + + public Set getAll(IMqttsnContext context){ + Set set = topicLookups.get(context); + if(set == null){ + synchronized (this){ + if((set = topicLookups.get(context)) == null){ + set = Collections.synchronizedSet(new HashSet<>()); + topicLookups.put(context, set); + } + } + } + return set; + } + + protected Map getRegistrationsInternal(IMqttsnContext context, boolean confirmedOnly){ + Set set = getAll(context); + Map map = new HashMap<>(); + synchronized (set){ + Iterator itr = set.iterator(); + while(itr.hasNext()){ + ConfirmableTopicRegistration reg = itr.next(); + if(!confirmedOnly || reg.confirmed){ + map.put(reg.topicPath, reg.aliasId); + } + } + } + return map; + } + + @Override + protected boolean addOrUpdateRegistration(IMqttsnContext context, String topicPath, int alias) throws MqttsnException { + + if(topicPath == null || topicPath.trim().length() == 0) + throw new MqttsnExpectationFailedException("null or empty topic path not allowed"); + Set set = getAll(context); + boolean updated = false; + synchronized (set){ + Iterator itr = set.iterator(); + if(itr.hasNext()){ + ConfirmableTopicRegistration reg = itr.next(); + if(reg.topicPath.equals(topicPath)){ + reg.confirmed = true; + reg.setAliasId(alias); + updated = true; + } + } + if(!updated){ + set.add(new ConfirmableTopicRegistration(topicPath, alias, true)); + } + } + + return !updated; + } + + @Override + protected Map getPredefinedTopicsForString(IMqttsnContext context) { + Map m = registry.getOptions().getPredefinedTopics(); + return m == null ? Collections.emptyMap() : m; + } + + @Override + protected Map getPredefinedTopicsForInteger(IMqttsnContext context) { + return getPredefinedTopicsForString(context); + } + + @Override + public void clearAll() throws MqttsnException { + topicLookups.clear(); + } + + @Override + public void clear(IMqttsnContext context, boolean hardClear) throws MqttsnException { + if(hardClear){ + topicLookups.remove(context); + } else{ + Set set = topicLookups.get(context); + if(set != null){ + synchronized (set){ + Iterator itr = set.iterator(); + if(itr.hasNext()){ + ConfirmableTopicRegistration reg = itr.next(); + reg.confirmed = false; + } + } + } + } + } + + @Override + public void clear(IMqttsnContext context) throws MqttsnException { + topicLookups.remove(context); + } + + public static class ConfirmableTopicRegistration { + + boolean confirmed; + String topicPath; + int aliasId; + + public ConfirmableTopicRegistration(String topicPath, int aliasId, boolean confirmed){ + this.topicPath = topicPath; + this.aliasId = aliasId; + this.confirmed = confirmed; + } + + public boolean isConfirmed() { + return confirmed; + } + + public void setConfirmed(boolean confirmed) { + this.confirmed = confirmed; + } + + public String getTopicPath() { + return topicPath; + } + + public void setTopicPath(String topicPath) { + this.topicPath = topicPath; + } + + public int getAliasId() { + return aliasId; + } + + public void setAliasId(int aliasId) { + this.aliasId = aliasId; + } + + @Override + public String toString() { + return "ConfirmableTopicRegistration{" + + "confirmed=" + confirmed + + ", topicPath='" + topicPath + '\'' + + ", aliasId=" + aliasId + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ConfirmableTopicRegistration that = (ConfirmableTopicRegistration) o; + return topicPath.equals(that.topicPath); + } + + @Override + public int hashCode() { + int result = topicPath.hashCode(); + return result; + } + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/AbstractContextObject.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/AbstractContextObject.java new file mode 100644 index 00000000..d99287bc --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/AbstractContextObject.java @@ -0,0 +1,52 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.model; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +public abstract class AbstractContextObject implements IContextObject { + + private Map contextObjects = new HashMap<>(); + + public Serializable getContextObject(String contextObjectKey){ + return contextObjects.get(contextObjectKey); + } + + public boolean putContextObject(String contextObjectKey, Serializable contextObject){ + return contextObjects.put(contextObjectKey, contextObject) == null; + } + + @Override + public String toString() { + return "AbstractContextObject{" + + "contextObjects=" + contextObjects + + '}'; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IContextObject.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IContextObject.java new file mode 100644 index 00000000..47fe8d14 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IContextObject.java @@ -0,0 +1,38 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.model; + +import java.io.Serializable; + +public interface IContextObject extends Serializable { + + Serializable getContextObject(String contextObjectKey); + + boolean putContextObject(String contextObjectKey, Serializable contextObject); + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IMqttsnContext.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IMqttsnContext.java new file mode 100644 index 00000000..e65db120 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IMqttsnContext.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +public interface IMqttsnContext extends IContextObject { + + String getId(); +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IMqttsnSessionState.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IMqttsnSessionState.java new file mode 100644 index 00000000..f00d52c9 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/IMqttsnSessionState.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import java.util.Date; + +public interface IMqttsnSessionState { + + IMqttsnContext getContext(); + + MqttsnClientState getClientState(); + + Date getLastSeen(); + + Date getSessionStarted(); + + int getKeepAlive(); + + void setClientState(MqttsnClientState state); + + void setLastSeen(Date date); + + void setKeepAlive(int keepAlive); +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/INetworkContext.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/INetworkContext.java new file mode 100644 index 00000000..2f7fbc24 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/INetworkContext.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import org.slj.mqtt.sn.net.NetworkAddress; + +import java.io.Serializable; + +public interface INetworkContext extends IContextObject { + + int getReceivePort(); + + void setReceivePort(int port); + + NetworkAddress getNetworkAddress(); + + void setNetworkAddress(NetworkAddress address); +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/InflightMessage.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/InflightMessage.java new file mode 100644 index 00000000..5939c4fc --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/InflightMessage.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import org.slj.mqtt.sn.spi.IMqttsnMessage; + +import java.io.Serializable; + +public class InflightMessage implements Serializable { + + public static enum DIRECTION {SENDING, RECEIVING} + + transient MqttsnWaitToken token; + IMqttsnMessage message; + long time; + DIRECTION direction; + + public InflightMessage(IMqttsnMessage message, DIRECTION direction, MqttsnWaitToken token) { + this.time = System.currentTimeMillis(); + this.direction = direction; + this.message = message; + this.token = token; + } + + public long getTime(){ + return time; + } + + public IMqttsnMessage getMessage(){ + return message; + } + + public MqttsnWaitToken getToken() { + return token; + } + + public DIRECTION getDirection() { + return direction; + } + + public void setTime(long time) { + this.time = time; + } + + @Override + public String toString() { + return "InflightMessage{" + + "token=" + token + + ", message=" + message + + ", time=" + time + + ", direction=" + direction + + '}'; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnClientState.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnClientState.java new file mode 100644 index 00000000..b018df14 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnClientState.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +public enum MqttsnClientState { + PENDING, CONNECTED, DISCONNECTED, AWAKE, ASLEEP, LOST +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnContext.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnContext.java new file mode 100644 index 00000000..16370591 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnContext.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import java.util.Objects; + +public class MqttsnContext extends AbstractContextObject implements IMqttsnContext { + + private String id; + + public MqttsnContext(){ + + } + + public MqttsnContext(String id) { + this.id = id; + } + + @Override + public String getId() { + return id; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MqttsnContext that = (MqttsnContext) o; + return id.equals(that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return id; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnOptions.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnOptions.java new file mode 100644 index 00000000..0d08b053 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnOptions.java @@ -0,0 +1,797 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.net.NetworkAddress; +import org.slj.mqtt.sn.spi.MqttsnRuntimeException; +import org.slj.mqtt.sn.utils.MqttsnUtils; +import org.slj.mqtt.sn.utils.TopicPath; + +import java.util.HashMap; +import java.util.Map; + +/** + * The options class allows you to control aspects of the MQTT-SN engines lifecycle and functionality. The options + * are generally applicable to both client and gateway runtimes, in limited cases, an option may only apply + * to either client OR gateway. + * + * Default values have been specified to be sensible for use in most cases. It is advised that defaults are only + * changed when you have a solid understanding of what you are changing, the imnpacts the changes could have. + */ +public class MqttsnOptions { + + /** + * By default, contexts active message timeout will not be monitored + */ + public static final String DEFAULT_SIMPLE_LOG_PATTERN = "[%1$tc] %4$s %2$s - %5$s %6$s%n"; + + /** + * By default the runtime will NOT remove receiving messages from the state service + */ + public static final boolean DEFAULT_REAP_RECEIVING_MESSAGES = false; + + /** + * The number of retries that shall be attempted before disconnecting the context + */ + public static final int DEFAULT_MAX_ERROR_RETRIES = 3; + + /** + * The interval (in milliseconds) between error retries + */ + public static final int DEFAULT_MAX_ERROR_RETRY_TIME = 10000; + + /** + * By default, contexts active message timeout will not be monitored + */ + public static final int DEFAULT_ACTIVE_CONTEXT_TIMEOUT = 20000; + + /** + * By default, discover is NOT enabled on either the client or the gateway. + */ + public static final boolean DEFAULT_WIRE_LOGGING_ENABLED = false; + + /** + * By default, discover is NOT enabled on either the client or the gateway. + */ + public static final boolean DEFAULT_DISCOVERY_ENABLED = false; + + /** + * By default, thread hand off is enabled on the gateway, and disabled on the client + */ + public static final boolean DEFAULT_THREAD_HANDOFF_ENABLED = true; + + /** + * When thread hand off is enabled, the default number of processing threads is 1 + */ + public static final int DEFAULT_HANDOFF_THREAD_COUNT = 1; + + /** + * Used to handle the outbound queue processing layer, when running as a gateway this should + * scale with the number of expected connected clients + */ + public static final int DEFAULT_STATE_PROCESSOR_THREAD_COUNT = 2; + + /** + * By default, 128 topics can reside in any 1 client registry + */ + public static final int DEFAULT_MAX_TOPICS_IN_REGISTRY = 128; + + /** + * By default, message IDs will start at 1 + */ + public static final int DEFAULT_MSG_ID_STARTS_AT = 1; + + /** + * By default, assigned aliases, handed out by the gateway will start at 1 + */ + public static final int DEFAULT_ALIAS_STARTS_AT = 1; + + /** + * By default, in either direction a client may have 1 message inflight (this is imposed by the specification). + */ + public static final int DEFAULT_MAX_MESSAGES_IN_FLIGHT = 1; + + /** + * By default, 100 publish messages can reside in a client message queue + */ + public static final int DEFAULT_MAX_MESSAGE_IN_QUEUE = 100; + + /** + * By default, timedout messages will be requeued + */ + public static final boolean DEFAULT_REQUEUE_ON_INFLIGHT_TIMEOUT = true; + + /** + * By default, the ASLEEP state will assume topic registrations will need to be reestablished + */ + public static final boolean DEFAULT_SLEEP_CLEARS_REGISTRATIONS = true; + + /** + * The maximum size of a supplied topic is 1024 characters + */ + public static final int DEFAULT_MAX_TOPIC_LENGTH = 1024; + + /** + * The maximum number of entries in a network registry is 1024 + */ + public static final int DEFAULT_MAX_NETWORK_ADDRESS_ENTRIES = 1024; + + /** + * By default, the max wait time for an acknowledgement is 10000 milliseconds + */ + public static final int DEFAULT_MAX_WAIT = 10000; + + /** + * By default, the max time a PUBLISH message will remain in flight is 30000 milliseconds + */ + public static final int DEFAULT_MAX_TIME_INFLIGHT = 30000; + + /** + * By default, the time to wait between activity (receiving and sending) is 1000 milliseconds + */ + public static final int DEFAULT_MIN_FLUSH_TIME = 1000; + + /** + * By default, the discovery search radius is 2 hops + */ + public static final int DEFAULT_SEARCH_GATEWAY_RADIUS = 2; + + /** + * By default, the time in seconds a client waits for a discovered gateway + */ + public static final int DEFAULT_DISCOVERY_TIME_SECONDS = 60 * 60; + + /** + * By default, the divisor is 4 + */ + public static final int DEFAULT_PING_DIVISOR = 4; + + /** + * By default instrumentation is switched off + */ + public static final boolean DEFAULT_INSTRUMENTATION_ENABLED = false; + + /** + * The default instrumentation interval is 60000 (60 seconds) + */ + public static final int DEFAULT_INSTRUMENTATION_INTERVAL = 60000; + + /** + * The default max protocol message size (including header and data) is 1024 bytes + */ + public static final int DEFAULT_MAX_PROTOCOL_SIZE = 1024; + + private String contextId; + private boolean threadHandoffFromTransport = DEFAULT_THREAD_HANDOFF_ENABLED; + private boolean enableDiscovery = DEFAULT_DISCOVERY_ENABLED; + private boolean sleepClearsRegistrations = DEFAULT_SLEEP_CLEARS_REGISTRATIONS; + private int handoffThreadCount = DEFAULT_HANDOFF_THREAD_COUNT; + private int minFlushTime = DEFAULT_MIN_FLUSH_TIME; + private int maxTopicsInRegistry = DEFAULT_MAX_TOPICS_IN_REGISTRY; + private int msgIdStartAt = DEFAULT_MSG_ID_STARTS_AT; + private int aliasStartAt = DEFAULT_ALIAS_STARTS_AT; + private int maxMessagesInflight = DEFAULT_MAX_MESSAGES_IN_FLIGHT; + private int maxMessagesInQueue = DEFAULT_MAX_MESSAGE_IN_QUEUE; + private boolean requeueOnInflightTimeout = DEFAULT_REQUEUE_ON_INFLIGHT_TIMEOUT; + private int maxTopicLength = DEFAULT_MAX_TOPIC_LENGTH; + private int maxNetworkAddressEntries = DEFAULT_MAX_NETWORK_ADDRESS_ENTRIES; + private int maxWait = DEFAULT_MAX_WAIT; + private int maxTimeInflight = DEFAULT_MAX_TIME_INFLIGHT; + private int searchGatewayRadius = DEFAULT_SEARCH_GATEWAY_RADIUS; + private int discoveryTime = DEFAULT_DISCOVERY_TIME_SECONDS; + private int pingDivisor = DEFAULT_PING_DIVISOR; + private boolean instrumentationEnabled = DEFAULT_INSTRUMENTATION_ENABLED; + private int instrumentationInterval = DEFAULT_INSTRUMENTATION_INTERVAL; + private int maxProtocolMessageSize = DEFAULT_MAX_PROTOCOL_SIZE; + private boolean wireLoggingEnabled = DEFAULT_WIRE_LOGGING_ENABLED; + private int activeContextTimeout = DEFAULT_ACTIVE_CONTEXT_TIMEOUT; + private String logPattern = DEFAULT_SIMPLE_LOG_PATTERN; + private int maxErrorRetries = DEFAULT_MAX_ERROR_RETRIES; + private int maxErrorRetryTime = DEFAULT_MAX_ERROR_RETRY_TIME; + private boolean reapReceivingMessages = DEFAULT_REAP_RECEIVING_MESSAGES; + private int stateProcessorThreadCount = DEFAULT_STATE_PROCESSOR_THREAD_COUNT; + + private Map predefinedTopics = new HashMap(); + private Map networkAddressEntries; + + /** + * How many threads should be used to process connected context message queues + * (should scale with the number of expected connected clients and the level + * of concurrency) + * + * @param stateProcessorThreadCount - Number of threads to use to service outbound queue processing + * + * @see {@link MqttsnOptions#DEFAULT_STATE_PROCESSOR_THREAD_COUNT} + * @return this configuration + */ + public MqttsnOptions withStateProcessorThreadCount(int stateProcessorThreadCount){ + this.stateProcessorThreadCount = stateProcessorThreadCount; + return this; + } + + /** + * Should the state service reap messages being recieved? + * + * @param reapReceivingMessages - Reap inbound messages + * + * @see {@link MqttsnOptions#DEFAULT_REAP_RECEIVING_MESSAGES} + * @return this configuration + */ + public MqttsnOptions withReapReceivingMessages(boolean reapReceivingMessages){ + this.reapReceivingMessages = reapReceivingMessages; + return this; + } + + /** + * Configure the log pattern on the environment + * + * @param logPattern - the log pattern applied to the SIMPLE log formatter + * + * @see {@link MqttsnOptions#DEFAULT_SIMPLE_LOG_PATTERN} + * @return this configuration + */ + public MqttsnOptions withLogPattern(String logPattern){ + this.logPattern = logPattern; + return this; + } + + /** + * Configure the behaviour or error retransmissions + * + * @param maxErrorRetries - The max number of retries attempted without a valid response before disconnecting the context + * + * @see {@link MqttsnOptions#DEFAULT_MAX_ERROR_RETRIES} + * @return this configuration + */ + public MqttsnOptions withMaxErrorRetries(int maxErrorRetries){ + this.maxErrorRetries = maxErrorRetries; + return this; + } + + /** + * Configure the behaviour or error retransmissions + * + * @param maxErrorRetryTime - The time between retries when a response is not received + * + * @see {@link MqttsnOptions#DEFAULT_MAX_ERROR_RETRY_TIME} + * @return this configuration + */ + public MqttsnOptions withMaxErrorRetryTime(int maxErrorRetryTime){ + this.maxErrorRetryTime = maxErrorRetryTime; + return this; + } + + /** + * When > 0, active context monitoring will notify the application when the context has not generated + * any active messages (PUBLISH, CONNECT, SUBSCRIBE, UNSUBSCRIBE) + * + * @param activeContextTimeout - the time alllowed between last message SENT or RECEIVED from context before + * notification to the connection listener + * + * @see {@link MqttsnOptions#DEFAULT_ACTIVE_CONTEXT_TIMEOUT} + * @return this configuration + */ + public MqttsnOptions withActiveContextTimeout(int activeContextTimeout){ + this.activeContextTimeout = activeContextTimeout; + return this; + } + + /** + * When enabled, output binary representation of all bytes sent and received + * from transport + * + * @param wireLoggingEnabled - output binary representation of all bytes sent and received from transport + * + * @see {@link MqttsnOptions#DEFAULT_WIRE_LOGGING_ENABLED} + * @return this configuration + */ + public MqttsnOptions withWireLoggingEnabled(boolean wireLoggingEnabled){ + this.wireLoggingEnabled = wireLoggingEnabled; + return this; + } + + /** + * Should the transport layer hand off messages it receives to a processing thread pool so the protocol loop does + * not become blocked by longing running operations. NB: it is advised that the transport loop is kept as quick as + * possible, changing this to false could result is long pauses for concurrent clients. + * + * @see {@link MqttsnOptions#DEFAULT_THREAD_HANDOFF_ENABLED} + * + * @param threadHandoffFromTransport - Should the transport layer hand off messages it receives to a processing thread pool so the protocol loop does + * not become blocked by longing running operations. + * @return this configuration + */ + public MqttsnOptions withThreadHandoffFromTransport(boolean threadHandoffFromTransport){ + this.threadHandoffFromTransport = threadHandoffFromTransport; + return this; + } + + /** + * When threadHandoffFromTransport is set to true, how many threads should be made available in the + * managed pool to handle processing. + * + * @see {@link MqttsnOptions#DEFAULT_HANDOFF_THREAD_COUNT} + * + * @param handoffThreadCount - When threadHandoffFromTransport is set to true, how many threads should be made available in the + * managed pool to handle processing + * @return this configuration + */ + public MqttsnOptions withHandoffThreadCount(int handoffThreadCount){ + this.handoffThreadCount = handoffThreadCount; + return this; + } + + /** + * The idle time between receiving a message and starting a new publish operation (where number of messages on a client queue > 0) + * + * @see {@link MqttsnOptions#DEFAULT_MIN_FLUSH_TIME} + * + * @param minFlushTime - The idle time between receiving a message and starting a new publish operation (where number of messages on a client queue > 0) + * @return this configuration + */ + public MqttsnOptions withMinFlushTime(int minFlushTime){ + this.minFlushTime = minFlushTime; + return this; + } + + /** + * When a client enters the ASLEEP state, should the NORMAL topic registered alias's be cleared down and reestablished during the + * next AWAKE | ACTIVE states. Setting this to false, will mean the gateway will resend REGISTER messages during an AWAKE ping. + * + * @see {@link MqttsnOptions#DEFAULT_SLEEP_CLEARS_REGISTRATIONS} + * + * @param sleepClearsRegistrations - When a client enters the ASLEEP state, should the NORMAL topic registered alias's be cleared down and reestablished during the next AWAKE | ACTIVE states. + * @return this configuration + */ + public MqttsnOptions withSleepClearsRegistrations(boolean sleepClearsRegistrations){ + this.sleepClearsRegistrations = sleepClearsRegistrations; + return this; + } + + /** + * Number of hops to allow broadcast messages + * + * @see {@link MqttsnOptions#DEFAULT_SEARCH_GATEWAY_RADIUS} + * + * @param searchGatewayRadius - Number of hops to allow broadcast messages + * @return this configuration + */ + public MqttsnOptions withSearchGatewayRadius(int searchGatewayRadius){ + this.searchGatewayRadius = searchGatewayRadius; + return this; + } + + /** + * How many messages should be allowed in a client's queue (either to send or buffer from the gateway). + * + * @see {@link MqttsnOptions#DEFAULT_MAX_MESSAGE_IN_QUEUE} + * + * @param maxMessagesInQueue - How many messages should be allowed in a client's queue. + * @return this configuration + */ + public MqttsnOptions withMaxMessagesInQueue(int maxMessagesInQueue){ + this.maxMessagesInQueue = maxMessagesInQueue; + return this; + } + + /** + * When a PUBLISH QoS 1,2 message has been in an unconfirmed state for a period of time, + * should it be requeued for a second DUP sending attempt or discarded. + * + * @see {@link MqttsnOptions#DEFAULT_REQUEUE_ON_INFLIGHT_TIMEOUT} + * + * @param requeueOnInflightTimeout - When a PUBLISH QoS 1,2 message has been in an unconfirmed state for a period of time, + * should it be requeued for a second DUP sending attempt or discarded + * @return this configuration + */ + public MqttsnOptions withRequeueOnInflightTimeout(boolean requeueOnInflightTimeout){ + this.requeueOnInflightTimeout = requeueOnInflightTimeout; + return this; + } + + /** + * Time in millis a PUBLISH message will reside in the INFLIGHT (unconfirmed) state before it is considered DUP (errord). + * + * @see {@link MqttsnOptions#DEFAULT_MAX_TIME_INFLIGHT} + * + * @param maxTimeInflight - Time in millis a PUBLISH message will reside in the INFLIGHT (unconfirmed) state before it is considered DUP (errord). + * @return this configuration + */ + public MqttsnOptions withMaxTimeInflight(int maxTimeInflight){ + this.maxTimeInflight = maxTimeInflight; + return this; + } + + /** + * Maximum number of messages allowed INFLIGHT at any given point in time. NB: the specification allows for a single message in flight in either direction. + * WARNING: changing this default value could lead to unpredictable behaviour depending on the gateway capability. + * + * @see {@link MqttsnOptions#DEFAULT_MAX_MESSAGES_IN_FLIGHT} + * + * @param maxMessagesInflight - Maximum number of messages allowed INFLIGHT at any given point in time. NB: the specification allows for a single message in flight in either direction. + * @return this configuration + */ + public MqttsnOptions withMaxMessagesInflight(int maxMessagesInflight){ + this.maxMessagesInflight = maxMessagesInflight; + return this; + } + + /** + * The maximum time (in millis) that an acknowledged message will wait to be considered successfully confirmed + * by the gateway. + * + * @see {@link MqttsnOptions#DEFAULT_MAX_WAIT} + * + * @param maxWait - Time in millis acknowledged message will wait before an error is thrown + * @return this configuration + */ + public MqttsnOptions withMaxWait(int maxWait){ + this.maxWait = maxWait; + return this; + } + + /** + * Add a predefined topic alias to the registry to be used in all interactions. + * NB: these should be known by both the client and the gateway to enable successful use of + * the PREDEFINED alias types. + * + * @param topicPath - The topic path to register e.g. "foo/bar" + * @param alias - The alias of the topic path to match + * @return this configuration + */ + public MqttsnOptions withPredefinedTopic(String topicPath, int alias){ + if(!TopicPath.isValidTopic(topicPath, Math.max(maxTopicLength, MqttsnConstants.USIGNED_MAX_16))){ + throw new MqttsnRuntimeException("invalid topic path " + topicPath); + } + + if(!MqttsnUtils.validUInt16(alias)){ + throw new MqttsnRuntimeException("invalid topic alias " + alias); + } + +// if(predefinedTopics == null){ +// synchronized (this) { +// if (predefinedTopics == null) { +// predefinedTopics = new HashMap(); +// } +// } +// } + predefinedTopics.put(topicPath, alias); + return this; + } + + /** + * The maximum length of a topic allowed, including all wildcard or separator characters. + * + * @see {@link MqttsnOptions#DEFAULT_MAX_TOPIC_LENGTH} + * + * @param maxTopicLength - The maximum length of a topic allowed, including all wildcard or separator characters. + * @return this configuration + */ + public MqttsnOptions withMaxTopicLength(int maxTopicLength){ + this.maxTopicLength = maxTopicLength; + return this; + } + + /** + * The number at which messageIds start, typically this should be 1. + * + * @see {@link MqttsnOptions#DEFAULT_MSG_ID_STARTS_AT} + * + * @param msgIdStartAt - The number at which messageIds start, typically this should be 1. + * @return this configuration + */ + public MqttsnOptions withMsgIdsStartAt(int msgIdStartAt){ + this.msgIdStartAt = msgIdStartAt; + return this; + } + + /** + * The maximum number of NORMAL topics allowed in the topic registry. + * NB: Realistically an application should not need many hundreds of topics in their hierarchy + * + * @see {@link MqttsnOptions#DEFAULT_MAX_TOPICS_IN_REGISTRY} + * + * @param maxTopicsInRegistry - The maximum number of NORMAL topics allowed in the topic registry. + * @return this configuration + */ + public MqttsnOptions withMaxTopicsInRegistry(int maxTopicsInRegistry){ + this.maxTopicsInRegistry = maxTopicsInRegistry; + return this; + } + + /** + * Should discovery be enabled. When enabled the transport layer will run its broadcast threads and + * allow dynamic gateway / client binding. + * + * @see {@link MqttsnOptions#DEFAULT_DISCOVERY_ENABLED} + * + * @param enableDiscovery - Should discovery be enabled. + * @return this configuration + */ + public MqttsnOptions withDiscoveryEnabled(boolean enableDiscovery){ + this.enableDiscovery = enableDiscovery; + return this; + } + + /** + * The maximum number of addresses allowed in the network registry. An address is a network location mapped + * to a clientId + * + * @see {@link MqttsnOptions#DEFAULT_MAX_NETWORK_ADDRESS_ENTRIES} + * + * @param maxNetworkAddressEntries - The maximum number of addresses allowed in the network registry + * @return this configuration + */ + public MqttsnOptions withMaxNetworkAddressEntries(int maxNetworkAddressEntries){ + this.maxNetworkAddressEntries = maxNetworkAddressEntries; + return this; + } + + /** + * The starting value of assigned NORMAL topic aliases that the gateway hands out. + * + * @see {@link MqttsnOptions#DEFAULT_ALIAS_STARTS_AT} + * + * @param aliasStartAt - The starting value of assigned NORMAL topic aliases that the gateway hands out. + * @return this configuration + */ + public MqttsnOptions withAliasStartAt(int aliasStartAt){ + this.aliasStartAt = aliasStartAt; + return this; + } + + /** + * The contextId is a general term for EITHER the clientId when running as a client or the gatewayId + * when running as a gateway. It should conform to the specification. It is advised that it contains between + * 1-23 alpha numeric characters from the ASCII character set. + * + * NB: When running in gateway mode, this is a mandatory item that should be set by the application. + * + * @param contextId - The contextId is a general term for EITHER the clientId when running as a client or the gatewayId + * when running as a gateway. + * @return this configuration + */ + public MqttsnOptions withContextId(String contextId){ + this.contextId = contextId; + return this; + } + + /** + * The time (in seconds) a client will wait for a broadcast during CONNECT before giving up + * + * NB: only applicable to client + * + * @see {@link MqttsnOptions#DEFAULT_DISCOVERY_TIME_SECONDS} + * + * @param discoveryTime - Time (in seconds) a client will wait for a broadcast during CONNECT before giving up + * when running as a client. + * @return this configuration + */ + public MqttsnOptions withDiscoveryTime(int discoveryTime){ + this.discoveryTime = discoveryTime; + return this; + } + + /** + * The divisor to use for the ping window, the dividend being the CONNECT keepAlive resulting + * in the quotient which is the time (since last sent message) each ping will be issued + * + * For example a 60 seconds session with a divisor of 4 will yield 15 second pings between + * activity + * + * NB: only applicable to client + * + * @see {@link MqttsnOptions#DEFAULT_PING_DIVISOR} + * + * @param pingDivisor - The divisor to use for the ping window + * @return this configuration + */ + public MqttsnOptions withPingDivisor(int pingDivisor){ + this.pingDivisor = pingDivisor; + return this; + } + + /** + * Should instrumentation be enabled. When enabled the runtime will call registered instrumentation + * providers on the {@link MqttsnOptions#getInstrumentationInterval} period and output the data + * to the standard logging. + * + * @param instrumentationEnabled - Should instrumentation be enabled + * @return this configuration + */ + public MqttsnOptions withInstrumentationEnabled(boolean instrumentationEnabled){ + this.instrumentationEnabled = instrumentationEnabled; + return this; + } + + /** + * The interval between instrumentation sampling when it is enabled. + * + * @param instrumentationInterval The interval between instrumentation sampling when it is enabled. + * @return this configuration + */ + public MqttsnOptions withInstrumentationInterval(int instrumentationInterval){ + this.instrumentationInterval = instrumentationInterval; + return this; + } + + /** + * The max allowable size of protocol messages that will be sent or received by the system. + * NOTE: this differs from transport level max sizes which will be deterimed and constrained by the + * MTU of the transport + * + * @param maxProtocolMessageSize - The max allowable size of protocol messages. + * @return this configuration + */ + public MqttsnOptions withMaxProtocolMessageSize(int maxProtocolMessageSize){ + this.maxProtocolMessageSize = maxProtocolMessageSize; + return this; + } + + /** + * Sets the locations of known clients or gateways on the network. When running as a client and discovery is not enabled, + * it is mandatory that at least 1 gateway entry be supplied, which will be the gateway the client talks to. In gateway + * mode, the registry is populated dynamically. + * @param contextId - the contextId of the known remote location + * @param address - the network address of the known remote location + * @return this config + */ + public MqttsnOptions withNetworkAddressEntry(String contextId, NetworkAddress address){ + if(networkAddressEntries == null){ + synchronized (this) { + if (networkAddressEntries == null) { + networkAddressEntries = new HashMap(); + } + } + } + networkAddressEntries.put(contextId, address); + return this; + } + + public Map getNetworkAddressEntries() { + return networkAddressEntries; + } + + public int getAliasStartAt() { + return aliasStartAt; + } + + public int getMsgIdStartAt() { + return msgIdStartAt; + } + + public int getMaxTopicsInRegistry() { + return maxTopicsInRegistry; + } + + public boolean isEnableDiscovery() { + return enableDiscovery; + } + + public int getMaxTopicLength() { + return maxTopicLength; + } + + public String getContextId() { + return contextId; + } + + public int getMaxTimeInflight() { + return maxTimeInflight; + } + + public int getMaxNetworkAddressEntries() { + return maxNetworkAddressEntries; + } + + public Map getPredefinedTopics() { + return predefinedTopics; + } + + public int getMaxMessagesInflight() { + return maxMessagesInflight; + } + + public int getMaxMessagesInQueue() { + return maxMessagesInQueue; + } + + public int getMaxWait() { + return maxWait; + } + + public int getHandoffThreadCount() { + return handoffThreadCount; + } + + public int getSearchGatewayRadius() { + return searchGatewayRadius; + } + + public int getMinFlushTime() { + return minFlushTime; + } + + public boolean isSleepClearsRegistrations() { + return sleepClearsRegistrations; + } + + public int getDiscoveryTime() { + return discoveryTime; + } + + public int getPingDivisor() { + return pingDivisor; + } + + public boolean isInstrumentationEnabled() { + return instrumentationEnabled; + } + + public int getInstrumentationInterval() { + return instrumentationInterval; + } + + public boolean isThreadHandoffFromTransport() { + return threadHandoffFromTransport; + } + + public boolean isRequeueOnInflightTimeout() { + return requeueOnInflightTimeout; + } + + public int getMaxProtocolMessageSize() { + return maxProtocolMessageSize; + } + + public boolean isWireLoggingEnabled() { + return wireLoggingEnabled; + } + + public int getActiveContextTimeout() { + return activeContextTimeout; + } + + public String getLogPattern() { + return logPattern; + } + + public int getMaxErrorRetries() { + return maxErrorRetries; + } + + public int getMaxErrorRetryTime() { + return maxErrorRetryTime; + } + + public boolean isReapReceivingMessages() { + return reapReceivingMessages; + } + + public int getStateProcessorThreadCount() { + return stateProcessorThreadCount; + } +} \ No newline at end of file diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnQueueAcceptException.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnQueueAcceptException.java new file mode 100644 index 00000000..b85212bd --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnQueueAcceptException.java @@ -0,0 +1,45 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.model; + +public class MqttsnQueueAcceptException extends Exception{ + public MqttsnQueueAcceptException() { + } + + public MqttsnQueueAcceptException(String message) { + super(message); + } + + public MqttsnQueueAcceptException(String message, Throwable cause) { + super(message, cause); + } + + public MqttsnQueueAcceptException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnSessionState.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnSessionState.java new file mode 100644 index 00000000..2d2b4c80 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnSessionState.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import java.util.Date; + +public class MqttsnSessionState implements IMqttsnSessionState { + + private IMqttsnContext context; + private MqttsnClientState state; + private Date lastSeen; + private int keepAlive; + private Date sessionStarted; + + public MqttsnSessionState(IMqttsnContext context, MqttsnClientState state){ + this.context = context; + sessionStarted = new Date(); + this.state = state; + } + + @Override + public IMqttsnContext getContext() { + return context; + } + + @Override + public MqttsnClientState getClientState() { + return state; + } + + public void setContext(IMqttsnContext context) { + this.context = context; + } + + public void setClientState(MqttsnClientState state) { + this.state = state; + } + + @Override + public Date getLastSeen() { + return lastSeen; + } + + public void setLastSeen(Date lastSeen) { + this.lastSeen = lastSeen; + } + + @Override + public int getKeepAlive() { + return keepAlive; + } + + public void setKeepAlive(int keepAlive) { + this.keepAlive = keepAlive; + } + + @Override + public Date getSessionStarted() { + return sessionStarted; + } + + public void setSessionStarted(Date sessionStarted) { + this.sessionStarted = sessionStarted; + } + + @Override + public String toString() { + return "MqttsnSessionState{" + + "context=" + context + + ", state=" + state + + ", lastSeen=" + lastSeen + + ", keepAlive=" + keepAlive + + ", sessionStarted=" + sessionStarted + + '}'; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnWaitToken.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnWaitToken.java new file mode 100644 index 00000000..ecdf76d0 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/MqttsnWaitToken.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import org.slj.mqtt.sn.spi.IMqttsnMessage; + +import java.util.UUID; + +public class MqttsnWaitToken { + + private volatile boolean error = false; + private volatile boolean complete = false; + private volatile QueuedPublishMessage queuedPublishMessage; + private volatile IMqttsnMessage message; + private volatile IMqttsnMessage responseMessage; + + public MqttsnWaitToken(QueuedPublishMessage queuedPublishMessage){ + this.queuedPublishMessage = queuedPublishMessage; + } + + public MqttsnWaitToken(IMqttsnMessage message){ + this.message = message; + } + + public IMqttsnMessage getMessage() { + return message; + } + + public void setMessage(IMqttsnMessage message) { + this.message = message; + } + + public IMqttsnMessage getResponseMessage() { + return responseMessage; + } + + public void setResponseMessage(IMqttsnMessage responseMessage) { + this.responseMessage = responseMessage; + } + + public static MqttsnWaitToken from(QueuedPublishMessage queuedPublishMessage){ + return new MqttsnWaitToken(queuedPublishMessage); + } + + public static MqttsnWaitToken from(IMqttsnMessage message){ + return new MqttsnWaitToken(message); + } + + public boolean isComplete() { + return complete; + } + + public boolean isError() { + return error; + } + + public void markError() { + complete = true; + error = true; + } + + public void markComplete() { + this.complete = true; + error = false; + } + + @Override + public String toString() { + return "MqttsnWaitToken{" + + "error=" + error + + ", complete=" + complete + + ", message=" + message + + ", responseMessage=" + responseMessage + + '}'; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/QueuedPublishMessage.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/QueuedPublishMessage.java new file mode 100644 index 00000000..2262e081 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/QueuedPublishMessage.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import java.io.Serializable; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Lightweight meta-data reference to a message which will reside in client queues. NOTE: the payload of + * the message itself NOR the topic specification are included, this can be obtained JIT from the + * appropriate registries so we dont duplicate data across many queues. + */ +public class QueuedPublishMessage implements Serializable, Comparable { + + private Date created; + private String topicPath; + private int grantedQoS; + private int retryCount; + private UUID messageId; + private boolean retained; + private transient MqttsnWaitToken token; + + public QueuedPublishMessage() { + } + + public QueuedPublishMessage(UUID messageId, String topicPath, int grantedQoS) { + this.created = new Date(); + this.messageId = messageId; + this.topicPath = topicPath; + this.grantedQoS = grantedQoS; + this.retryCount = 0; + } + + public UUID getMessageId() { + return messageId; + } + + public void setMessageId(UUID messageId) { + this.messageId = messageId; + } + + public boolean getRetained() { + return retained; + } + + public void setRetained(boolean retained) { + this.retained = retained; + } + + public int getRetryCount() { + return retryCount; + } + + public void incrementRetry(){ + retryCount++; + } + + public String getTopicPath() { + return topicPath; + } + + public void setTopicPath(String topicPath) { + this.topicPath = topicPath; + } + + public int getGrantedQoS() { + return grantedQoS; + } + + public void setGrantedQoS(int grantedQoS) { + this.grantedQoS = grantedQoS; + } + + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } + + public MqttsnWaitToken getToken() { + return token; + } + + public void setToken(MqttsnWaitToken token) { + this.token = token; + } + + @Override + public String toString() { + return "QueuedPublishMessage{" + + "created=" + created + + ", topicPath='" + topicPath + '\'' + + ", grantedQoS=" + grantedQoS + + ", retryCount=" + retryCount + + ", messageId=" + messageId + + ", retained=" + retained + + '}'; + } + + public Date getCreated() { + return created; + } + + @Override + public int compareTo(Object o) { + if(o instanceof QueuedPublishMessage){ + return created.compareTo(((QueuedPublishMessage)o).getCreated()); + } + return 0; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/RequeueableInflightMessage.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/RequeueableInflightMessage.java new file mode 100644 index 00000000..3403099d --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/RequeueableInflightMessage.java @@ -0,0 +1,34 @@ +package org.slj.mqtt.sn.model; + +import org.slj.mqtt.sn.spi.IMqttsnMessage; + +public class RequeueableInflightMessage extends InflightMessage { + + QueuedPublishMessage queuedPublishMessage; + + public RequeueableInflightMessage(QueuedPublishMessage queuedPublishMessage, IMqttsnMessage message) { + super(message, DIRECTION.SENDING, queuedPublishMessage.getToken()); + if(queuedPublishMessage.getToken() != null) + queuedPublishMessage.getToken().setMessage(message); + + this.queuedPublishMessage = queuedPublishMessage; + } + + public QueuedPublishMessage getQueuedPublishMessage() { + return queuedPublishMessage; + } + + public void setQueuedPublishMessage(QueuedPublishMessage queuedPublishMessage) { + this.queuedPublishMessage = queuedPublishMessage; + } + + @Override + public String toString() { + return "RequeueableInflightMessage{" + + "token=" + token + + ", message=" + message + + ", time=" + time + + ", queuedPublishMessage=" + queuedPublishMessage + + '}'; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/Subscription.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/Subscription.java new file mode 100644 index 00000000..dff55edb --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/Subscription.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import org.slj.mqtt.sn.utils.TopicPath; + +import java.util.Objects; + +public class Subscription { + + TopicPath topicPath; + int QoS; + + public Subscription(){ + + } + + public Subscription(TopicPath topicPath){ + this.topicPath = topicPath; + } + + public Subscription(TopicPath topicPath, int qoS) { + this.topicPath = topicPath; + QoS = qoS; + } + + public TopicPath getTopicPath() { + return topicPath; + } + + public void setTopicPath(TopicPath topicPath) { + this.topicPath = topicPath; + } + + public int getQoS() { + return QoS; + } + + public void setQoS(int qoS) { + QoS = qoS; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Subscription that = (Subscription) o; + return Objects.equals(topicPath, that.topicPath); + } + + @Override + public int hashCode() { + return Objects.hash(topicPath); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/TopicInfo.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/TopicInfo.java new file mode 100644 index 00000000..67279261 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/model/TopicInfo.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.model; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class TopicInfo { + + // ** when subscribing to a wildcard topic, we return the wildcard alias response **/ + public static TopicInfo WILD = new TopicInfo(MqttsnConstants.TOPIC_TYPE.NORMAL, 0x00); + + MqttsnConstants.TOPIC_TYPE type; + int topicId; + String topicPath; + + public TopicInfo(MqttsnConstants.TOPIC_TYPE type, int topicId){ + this.type = type; + this.topicId = topicId; + } + + public TopicInfo(MqttsnConstants.TOPIC_TYPE type, String topicPath){ + this.type = type; + this.topicPath = topicPath; + } + + public MqttsnConstants.TOPIC_TYPE getType() { + return type; + } + + public void setType(MqttsnConstants.TOPIC_TYPE type) { + this.type = type; + } + + public int getTopicId() { + return topicId; + } + + public void setTopicId(int topicId) { + this.topicId = topicId; + } + + public String getTopicPath() { + return topicPath; + } + + public void setTopicPath(String topicPath) { + this.topicPath = topicPath; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("TopicInfo{"); + sb.append("type=").append(type); + sb.append(", topicId=").append(topicId); + sb.append(", topicPath='").append(topicPath).append('\''); + sb.append('}'); + return sb.toString(); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnTcpOptions.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnTcpOptions.java new file mode 100644 index 00000000..1766cc5e --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnTcpOptions.java @@ -0,0 +1,293 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.net; + +import javax.net.ServerSocketFactory; +import javax.net.SocketFactory; +import javax.net.ssl.SSLSocketFactory; + +public class MqttsnTcpOptions { + + /** + * Default connect timeout is 10000 milliseconds + */ + public static int DEFAULT_CONNECT_TIMEOUT = 10000; + + /** + * Default socket timeout is 0 (infinte) + */ + public static int DEFAULT_SO_TIMEOUT = 0; + + /** + * By default TCP keep alive is enabled + */ + public static boolean DEFAULT_TCP_KEEPALIVE_ENABLED = true; + + /** + * Default port is 1883 for gateways. + */ + public static int DEFAULT_PORT = 1883; + + /** + * Default secure port is 8883 for gateways. + */ + public static int DEFAULT_SECURE_PORT = 8883; + + /** + * Default localhost is null + */ + public static String DEFAULT_HOST = null; + + /** + * Default SSL cipher suites to enable + */ + public static String[] DEFAULT_SSL_CIPHER_SUITES = null; +// `new String[] { +// "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", +// "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256", +// "TLS_RSA_WITH_AES_256_CBC_SHA", +// "TLS_RSA_WITH_AES_256_CBC_SHA256" +// }; + + /** + * Default SSL protocols + */ + public static String[] DEFAULT_SSL_PROTOCOLS = new String[] { + "TLSv1","TLSv1.1","TLSv1.2" + }; + + /** + * Default SSL protocols + */ + public static boolean DEFAULT_IS_SECURE = false; + + /** + * Default max concurrent client connections is 100 + */ + public static int DEFAULT_MAX_CLIENT_CONNECTIONS = 100; + + /** + * Default keystore password is unset + */ + public static String DEFAULT_KEYSTORE_PASSWORD = "password"; + + /** + * Default read buffer size is 1024 + */ + public static int DEFAULT_READ_BUFFER_SIZE = 1024; + + /** + * Default read buffer size is 1024 + */ + public static String DEFAULT_SSL_ALGORITHM = "TLS"; + + SocketFactory clientSocketFactory = SocketFactory.getDefault(); + ServerSocketFactory serverSocketFactory = ServerSocketFactory.getDefault(); + + String host = DEFAULT_HOST; + int port = DEFAULT_PORT; + int securePort = DEFAULT_SECURE_PORT; + + int maxClientConnections = DEFAULT_MAX_CLIENT_CONNECTIONS; + int connectTimeout = DEFAULT_CONNECT_TIMEOUT; + int soTimeout = DEFAULT_SO_TIMEOUT; + boolean tcpKeepAliveEnabled = DEFAULT_TCP_KEEPALIVE_ENABLED; + int readBufferSize = DEFAULT_READ_BUFFER_SIZE; + + //-- SSL stuff + boolean secure = DEFAULT_IS_SECURE; + String[] sslProtocols = DEFAULT_SSL_PROTOCOLS; + String[] cipherSuites = DEFAULT_SSL_CIPHER_SUITES; + String keyStorePath = null; + String trustStorePath = null; + String keyStorePassword = DEFAULT_KEYSTORE_PASSWORD; + String trustStorePassword = DEFAULT_KEYSTORE_PASSWORD; + String sslAlgorithm = DEFAULT_SSL_ALGORITHM; + + public MqttsnTcpOptions withSecure(boolean secure){ + this.secure = secure; + return this; + } + + public MqttsnTcpOptions withSSLAlgorithm(String sslAlgorithm){ + this.sslAlgorithm = sslAlgorithm; + return this; + } + + public MqttsnTcpOptions withReadBufferSize(int readBufferSize){ + this.readBufferSize = readBufferSize; + return this; + } + + public MqttsnTcpOptions withKeystorePassword(String keyStorePassword){ + this.keyStorePassword = keyStorePassword; + return this; + } + + public MqttsnTcpOptions withTruststorePassword(String trustStorePassword){ + this.trustStorePassword = trustStorePassword; + return this; + } + + public MqttsnTcpOptions withTruststorePath(String trustStorePath){ + this.trustStorePath = trustStorePath; + return this; + } + + public MqttsnTcpOptions withKeystorePath(String keyStorePath){ + this.keyStorePath = keyStorePath; + return this; + } + + public MqttsnTcpOptions withMaxClientConnections(int maxClientConnections){ + this.maxClientConnections = maxClientConnections; + return this; + } + + public MqttsnTcpOptions withSSLProtocols(String[] sslProtocols){ + this.sslProtocols = sslProtocols; + return this; + } + + public MqttsnTcpOptions withCipherSuites(String[] cipherSuites){ + this.cipherSuites = cipherSuites; + return this; + } + + public MqttsnTcpOptions withTcpKeepAliveEnabled(boolean keepAliveEnabled){ + this.tcpKeepAliveEnabled = keepAliveEnabled; + return this; + } + + public MqttsnTcpOptions withSoTimeout(int soTimeout){ + this.soTimeout = soTimeout; + return this; + } + + public MqttsnTcpOptions withConnectTimeout(int connectTimeout){ + this.connectTimeout = connectTimeout; + return this; + } + + public MqttsnTcpOptions withHost(String host){ + this.host = host; + return this; + } + + public MqttsnTcpOptions withPort(int port){ + this.port = port; + return this; + } + + public MqttsnTcpOptions withSecurePort(int securePort){ + this.securePort = securePort; + return this; + } + + public MqttsnTcpOptions withServerSocketFactory(ServerSocketFactory serverSocketFactory){ + this.serverSocketFactory = serverSocketFactory; + return this; + } + + public MqttsnTcpOptions withSocketFactory(SocketFactory clientSocketFactory){ + this.clientSocketFactory = clientSocketFactory; + return this; + } + + public int getConnectTimeout() { + return connectTimeout; + } + + public int getSoTimeout() { + return soTimeout; + } + + public boolean isTcpKeepAliveEnabled() { + return tcpKeepAliveEnabled; + } + + public String[] getSslProtocols() { + return sslProtocols; + } + + public String[] getCipherSuites() { + return cipherSuites; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + public boolean isSecure() { + return secure; + } + + public int getMaxClientConnections() { + return maxClientConnections; + } + + public SocketFactory getClientSocketFactory() { + return clientSocketFactory; + } + + public ServerSocketFactory getServerSocketFactory() { + return serverSocketFactory; + } + + public String getKeyStorePassword() { + return keyStorePassword; + } + + public int getReadBufferSize() { + return readBufferSize; + } + + public int getSecurePort() { + return securePort; + } + + public String getKeyStorePath() { + return keyStorePath; + } + + public String getTrustStorePath() { + return trustStorePath; + } + + public String getTrustStorePassword() { + return trustStorePassword; + } + + public String getSslAlgorithm() { + return sslAlgorithm; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnTcpTransport.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnTcpTransport.java new file mode 100644 index 00000000..a3b96871 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnTcpTransport.java @@ -0,0 +1,561 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.net; + +import org.slj.mqtt.sn.impl.AbstractMqttsnTransport; +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.NetworkRegistryException; + +import javax.net.ssl.*; +import java.io.*; +import java.net.*; +import java.nio.ByteBuffer; +import java.security.*; +import java.security.cert.CertificateException; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; + +/** + * TCP IP implementation support. Can be run with SSL (TLS) enabled for secure communication. + * Supports running in both client and server mode. + */ +public class MqttsnTcpTransport + extends AbstractMqttsnTransport { + + protected final MqttsnTcpOptions options; + protected boolean clientMode = false; + + static AtomicInteger connectionCount = new AtomicInteger(0); + private final Object monitor = new Object(); + protected Handler clientHandler; + protected Server server; + + public MqttsnTcpTransport(MqttsnTcpOptions options, boolean clientMode) { + this.options = options; + this.clientMode = clientMode; + } + + @Override + public synchronized void start(IMqttsnRuntimeRegistry runtime) throws MqttsnException { + try { + super.start(runtime); + running = false; + if(clientMode){ + logger.log(Level.INFO, String.format("running in client mode, establishing tcp connection...")); + connectClient(); + } else { + logger.log(Level.INFO, String.format("running in server mode, establishing tcp acceptors...")); + startServer(); + } + running = true; + synchronized (monitor){ + monitor.notifyAll(); + } + } catch(Exception e){ + running = false; + throw new MqttsnException(e); + } + } + + @Override + public synchronized void stop() throws MqttsnException { + super.stop(); + try { + if(clientMode){ + logger.log(Level.INFO, String.format("closing in client mode, closing tcp connection(s)...")); + try { + if(clientHandler != null){ + clientHandler.close(); + } + } finally { + clientHandler = null; + } + } else { + logger.log(Level.INFO, String.format("closing in server mode, closing tcp connection(s)...")); + try { + if(server != null){ + server.close(); + } + } finally { + server = null; + } + } + } catch(Exception e){ + throw new MqttsnException(e); + } + } + + @Override + protected void writeToTransport(INetworkContext context, ByteBuffer data) throws MqttsnException { + try { + if(clientMode){ + if(clientHandler == null){ + //-- in edge cases, we may get called by other services when still connecting.. + //-- so defend against that + synchronized (monitor){ + logger.log(Level.WARNING, "waiting for TCP stack to come up..."); + monitor.wait(options.getConnectTimeout() + 1000); + } + } + if(clientHandler != null){ + clientHandler.write(drain(data)); + } + + } else { + if(server != null){ + server.write(context, drain(data)); + } + } + } catch(IOException | InterruptedException e){ + throw new MqttsnException("error writing to connection;", e); + } + } + + protected SSLContext initSSLContext() + throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException, UnrecoverableKeyException, KeyManagementException { + + String keyStorePath = options.getKeyStorePath(); + String trustStorePath = options.getTrustStorePath(); + logger.log(Level.INFO, String.format("initting SSL context with keystore [%s], truststore [%s]", keyStorePath, trustStorePath)); + + //-- keystore + KeyStore keyStore = null; + KeyManager[] keyManagers = null; + if(keyStorePath != null){ + File f = new File(keyStorePath); + if(!f.exists() || !f.canRead()) + throw new KeyStoreException("unable to read keyStore " + keyStorePath); + + keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + KeyManagerFactory kmf = KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()); + + try(InputStream keyStoreData = new FileInputStream(f)) { + char[] password = options.getKeyStorePassword().toCharArray(); + keyStore.load(keyStoreData, password); + Enumeration aliases = keyStore.aliases(); + while(aliases.hasMoreElements()){ + String el = aliases.nextElement(); + logger.log(Level.INFO, String.format("keystore contains alias [%s]", el)); + } + kmf.init(keyStore, password); + keyManagers = kmf.getKeyManagers(); + } + } + + TrustManager[] trustManagers = null; + if(trustStorePath == null){ + logger.log(Level.WARNING, "!! ssl operating in trust-all mode, do not use this in production, nominate a trust store !!"); + TrustManager trustAllCerts = new X509TrustManager() { + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return null; + } + public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + }; + trustManagers = new TrustManager[] { trustAllCerts }; + } else { + File f = new File(trustStorePath); + if(!f.exists() || !f.canRead()) + throw new KeyStoreException("unable to read trustStore " + trustStorePath); + + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + try(InputStream trustStoreData = new FileInputStream(f)) { + char[] password = options.getTrustStorePassword().toCharArray(); + trustStore.load(trustStoreData, password); + Enumeration aliases = keyStore.aliases(); + while(aliases.hasMoreElements()){ + String el = aliases.nextElement(); + logger.log(Level.INFO, String.format("truststore contains alias [%s]", el)); + } + TrustManagerFactory tmf = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(keyStore); + trustManagers = tmf.getTrustManagers(); + } + } + SSLContext ctx = SSLContext.getInstance(options.getSslAlgorithm()); + logger.log(Level.INFO, String.format("ssl initialised with algo [%s]", ctx.getProtocol())); + logger.log(Level.INFO, String.format("ssl initialised with JCSE provider [%s]", ctx.getProvider())); + logger.log(Level.INFO, String.format("ssl initialised with [%s] key manager(s)", keyManagers == null ? null : keyManagers.length)); + logger.log(Level.INFO, String.format("ssl initialised with [%s] trust manager(s)", trustManagers == null ? null : trustManagers.length)); + ctx.init(keyManagers, trustManagers, + SecureRandom.getInstanceStrong()); + return ctx; + + } + + protected void connectClient() throws MqttsnException { + try { + //-- check we have a valid network location to connect to + Optional remoteAddress = registry.getNetworkRegistry().first(); + if(!remoteAddress.isPresent()) throw new MqttsnException("need a remote location to connect to found "); + INetworkContext remoteContext = remoteAddress.get(); + + //-- create and bind local socket + if(clientHandler == null){ + synchronized (this){ + if(clientHandler == null){ + Socket clientSocket; + if(options.isSecure()){ + clientSocket = initSSLContext().getSocketFactory().createSocket(); + SSLSocket ssl = (SSLSocket) clientSocket; + if(options.getSslProtocols() != null) { + logger.log(Level.INFO, String.format("starting client-ssl with protocols [%s]", + Arrays.toString(options.getSslProtocols()))); + ssl.setEnabledProtocols(options.getSslProtocols()); + } + if(options.getCipherSuites() != null){ + logger.log(Level.INFO, String.format("starting client-ssl with cipher suites [%s]", + Arrays.toString(options.getCipherSuites()))); + ssl.setEnabledCipherSuites(options.getCipherSuites()); + } + + logger.log(Level.INFO, String.format("client-ssl enabled protocols [%s]", + Arrays.toString(ssl.getEnabledProtocols()))); + logger.log(Level.INFO, String.format("client-ssl enabled cipher suites [%s]", + Arrays.toString(ssl.getEnabledCipherSuites()))); + + } else { + clientSocket = options.getClientSocketFactory().createSocket(); + } + + //bind to the LOCAL address if specified, else bind to the OS default + InetSocketAddress localAddress = null; + if(options.getHost() != null){ + localAddress = new InetSocketAddress(options.getHost(), options.getPort()); + } + + clientSocket.bind(localAddress); + clientSocket.setSoTimeout(options.getSoTimeout()); + clientSocket.setKeepAlive(options.isTcpKeepAliveEnabled()); + + //-- if bound locally, connect to the remote if specified (running as a client) + if(clientSocket.isBound()){ + InetSocketAddress r = + new InetSocketAddress(remoteContext.getNetworkAddress().getHostAddress(), + remoteContext.getNetworkAddress().getPort()); + logger.log(Level.INFO, String.format("connecting client socket to [%s] -> [%s]", + remoteContext.getNetworkAddress().getHostAddress(), remoteContext.getNetworkAddress().getPort())); + clientSocket.connect(r, options.getConnectTimeout()); + } + + //-- need to be connected before handshake + if(clientSocket instanceof SSLSocket && + options.isSecure()){ + ((SSLSocket) clientSocket).startHandshake(); + } + + if(clientSocket.isBound() && clientSocket.isConnected()){ + clientHandler = new Handler(remoteContext, clientSocket, null); + clientHandler.start(); + } else { + logger.log(Level.SEVERE, "could not bind and connect socket to host, finished."); + } + } + } + } + } catch(Exception e){ + throw new MqttsnException(e); + } + } + + protected void startServer() + throws IOException, KeyStoreException, NoSuchAlgorithmException, KeyManagementException, + CertificateException, UnrecoverableKeyException { + if(server == null){ + synchronized (this) { + if (server == null) { + ServerSocket socket = null; + if(options.isSecure()){ + logger.log(Level.INFO, String.format("running in secure mode, tcp with TLS...")); + socket = initSSLContext().getServerSocketFactory().createServerSocket(options.getSecurePort()); + + SSLServerSocket ssl = (SSLServerSocket) socket; + if(options.getSslProtocols() != null) { + logger.log(Level.INFO, String.format("starting server-ssl with protocols [%s]", Arrays.toString(options.getSslProtocols()))); + ssl.setEnabledProtocols(options.getSslProtocols()); + } + if(options.getCipherSuites() != null){ + logger.log(Level.INFO, String.format("starting server-ssl with cipher suites [%s]", Arrays.toString(options.getCipherSuites()))); + ssl.setEnabledCipherSuites(options.getCipherSuites()); + } + + logger.log(Level.INFO, String.format("server-ssl enabled protocols [%s]", Arrays.toString(ssl.getEnabledProtocols()))); + logger.log(Level.INFO, String.format("server-ssl enabled cipher suites [%s]", Arrays.toString(ssl.getEnabledCipherSuites()))); + + } else { + socket = options.getServerSocketFactory().createServerSocket(options.getPort()); + } + server = new Server(socket); + server.start(); + } + } + } + } + + private class Server extends Thread implements ClosedListener{ + + private final ServerSocket serverSocket; + private Map connections = + Collections.synchronizedMap(new HashMap<>()); + + public Server(ServerSocket serverSocket){ + this.serverSocket = serverSocket; + setPriority(Thread.MIN_PRIORITY + 1); + setDaemon(false); + setName("mqtt-sn-tcp-server"); + } + + public void write(INetworkContext context, byte[] data) throws IOException { + Handler handler = connections.get(context); + if(handler == null) throw new IOException("no connected handler for context"); + handler.write(data); + } + + public void close(){ + if(connections != null && !connections.isEmpty()){ + connections.values().stream().forEach(c -> { + try { + c.close(); + } catch(Exception e){ + logger.log(Level.SEVERE, "error closing connection", e); + } + }); + } + } + + public void closed(Handler handler){ + if(connections != null && handler != null) + connections.remove(handler.descriptor.context); + } + + public void run(){ + logger.log(Level.INFO, String.format("starting TCP listener, accepting [%s] connections on port [%s]", + options.getMaxClientConnections(), serverSocket.getLocalPort())); + while(running){ + try { + Socket socket = serverSocket.accept(); + if(connections.size() >= options.getMaxClientConnections()){ + logger.log(Level.WARNING, "max connection limit reached, disconnecting socket"); + socket.close(); + + } else { + SocketAddress address = socket.getRemoteSocketAddress(); + logger.log(Level.INFO, + String.format("new connection accepted from [%s]", address)); + + NetworkAddress networkAddress = NetworkAddress.from((InetSocketAddress) address); + INetworkContext context = registry.getNetworkRegistry().getContext(networkAddress); + if(context == null){ + //-- if the network context does not exist in the registry, a new one is created by the factory - + //- NB: this is NOT auth, this is simply creating a context to which we can respond, auth can + //-- happen during the mqtt-sn context creation, at which point we can talk back to the device + //-- with error packets and the like + context = registry.getContextFactory().createInitialNetworkContext(networkAddress); + } + Handler handler = new Handler(context, socket, this); + connections.put(context, handler); + handler.start(); + } + + } catch (NetworkRegistryException | IOException | MqttsnException e){ + logger.log(Level.SEVERE, "error encountered accepting connection;", e); + } + } + } + } + + /** + * When running in server mode the handler will accept each connection lazily (not pre-pooled) + */ + class Handler extends Thread { + + private final SocketDescriptor descriptor; + private final ClosedListener listener; + + public Handler(INetworkContext context, Socket socket, ClosedListener listener) throws IOException { + this.descriptor = new SocketDescriptor(context, socket); + this.listener = listener; + setName("mqtt-sn-tcp-connection-" + connectionCount.incrementAndGet()); + setDaemon(false); + setPriority(Thread.NORM_PRIORITY); + } + + public void close() throws IOException { + descriptor.close(); + if(listener != null) listener.closed(this); + } + + public void write(byte[] data) throws IOException { + //-- TODO this needs buffering in + try { + if(descriptor.isOpen()){ + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("writing [%s] bytes to output stream", data.length)); + } + descriptor.os.write(data); + descriptor.os.flush(); + } + } catch(SocketException e){ + try { + if(descriptor != null) { + connectionLost(descriptor.context, e); + } + } finally { + try { + descriptor.close(); + } catch (IOException ex) { + logger.log(Level.WARNING, "error closing socket descriptor;", e); + } + } + } + } + + public void run(){ + try { + logger.log(Level.INFO, String.format("starting socket handler for [%s]", descriptor)); + while(running && descriptor.isOpen()){ + int count; + int messageLength = 0; + int messageLengthRemaining = 0; + byte[] buff = new byte[options.getReadBufferSize()]; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + while ((count = descriptor.is.read(buff)) > 0) { + boolean isNewMessage = baos.size() == 0; + baos.write(buff, 0, count); + if(isNewMessage){ + messageLength = registry.getCodec().readMessageSize(baos.toByteArray()); + messageLengthRemaining = messageLength; + } + + messageLengthRemaining -= count; + + if (messageLengthRemaining == 0 && baos.size() == messageLength) { + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("received [%s] bytes from socket for [%s], reset buffer", messageLength, descriptor)); + } + receiveFromTransport(descriptor.context, wrap(baos.toByteArray())); + messageLength = 0; + messageLengthRemaining = 0; + baos.reset(); + } + } + } + + if(count == 0 || count == -1) { + logger.log(Level.INFO, String.format("received [%s] bytes from socket handler (end of stream - EOF), ", count, descriptor)); + //throw here will cascade the error up to the runtime + throw new SocketException("EOF received"); + } + } + } + catch(SSLProtocolException | SocketException e){ + //-- socket close from underneath during reading + logger.log(Level.FINE, String.format("socket error [%s]", descriptor), e); + if(descriptor != null){ + if(!descriptor.closed){ + //it may have been that we asked for a stop and there was an issue closing the socket + //in which cast we didnt LOSE the connection.. only report lost if the + //service was meant to be running + connectionLost(descriptor.context, e); + } + } + } + catch(IOException e){ + throw new RuntimeException("error accepting data from client stream;",e); + } finally { + try { + descriptor.close(); + } catch (IOException e) { + logger.log(Level.WARNING, "error closing socket descriptor;", e); + } + } + } + } + + private class SocketDescriptor implements Closeable { + + private volatile boolean closed = false; + private INetworkContext context; + private Socket socket; + private InputStream is; + private OutputStream os; + + public SocketDescriptor(INetworkContext context, Socket socket) throws IOException { + this.context = context; + this.socket = socket; + if(socket.isClosed()) throw new IllegalArgumentException("cannot get a descriptor onto closed socket"); + is = socket.getInputStream(); + os = socket.getOutputStream(); + } + + public boolean isOpen(){ + return socket != null && socket.isConnected() && !socket.isClosed(); + } + + @Override + public String toString() { + return Objects.toString(context); + } + + @Override + public synchronized void close() throws IOException { + try { + if(!closed){ + int current = connectionCount.decrementAndGet(); + logger.log(Level.INFO, String.format("closing socket [%s], activeConnection(s) [%s]", this, current)); + closed = true; + try {is.close();} catch(Exception e){} + try {os.close();} catch(Exception e){} + try {socket.close();} catch(Exception e){} + } + } finally { + socket = null; + context = null; + os = null; + is = null; + } + } + } + + @Override + public void broadcast(IMqttsnMessage message) throws MqttsnException { + throw new UnsupportedOperationException("broadcast not supported on TCP"); + } +} + +interface ClosedListener { + void closed(MqttsnTcpTransport.Handler handler); +} \ No newline at end of file diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnUdpOptions.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnUdpOptions.java new file mode 100644 index 00000000..bd2cd059 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnUdpOptions.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.net; + +/** + * Options to configure the behaviour of the UDP transport layer + */ +public class MqttsnUdpOptions { + + /** + * Default max tranmission unit for UDP transport + */ + public static int DEFAULT_MTU = 1024; + + /** + * Default host binds to wildcard (kernel assigned) + */ + public static String DEFAULT_LOCAL_BIND_INTERFACE = "0.0.0.0"; + + /** + * Default local port for client mode is 0 (any available local port) for clients. + */ + public static int DEFAULT_LOCAL_CLIENT_PORT = 0; + + /** + * Default local port is 2442 for gateways. + */ + public static int DEFAULT_LOCAL_PORT = 2442; + + /** + * Default local DTLS port is 2443 for gateways and unspecified (any available local port) for clients. + */ + public static int DEFAULT_SECURE_PORT = 2443; + + /** + * Default multicast port is 2224 + */ + public static int DEFAULT_BROADCAST_PORT = 2224; + + /** + * Default receive buffer size is 1024 + */ + public static int DEFAULT_RECEIVE_BUFFER_SIZE = 1024; + + /** + * Default receive buffer size is 1024 + */ + public static boolean DEFAULT_BIND_BROADCAST_LISTENER = false; + + String host = DEFAULT_LOCAL_BIND_INTERFACE; + int port = DEFAULT_LOCAL_PORT; + int mtu = DEFAULT_MTU; + int securePort = DEFAULT_SECURE_PORT; + int broadcastPort = DEFAULT_BROADCAST_PORT; + int receiveBuffer = DEFAULT_RECEIVE_BUFFER_SIZE; + boolean bindBroadcastListener = DEFAULT_BIND_BROADCAST_LISTENER; + + /** + * Max allowable tranmission unit + * + * @see {@link MqttsnUdpOptions#DEFAULT_MTU} for default gateway value + * + * @param mtu - Mtu for the medium + * @return this config + */ + public MqttsnUdpOptions withMtu(int mtu){ + this.mtu = mtu; + return this; + } + + /** + * Which local port to use for datagram messaging. When running in client mode, set 0 (default) which will mean + * any available local port will be used. + * + * @see {@link MqttsnUdpOptions#DEFAULT_LOCAL_PORT} for default gateway value + * @see {@link MqttsnUdpOptions#DEFAULT_LOCAL_CLIENT_PORT} for default client value + * + * @param port - Which local port to use for datagram messaging + * @return this config + */ + public MqttsnUdpOptions withPort(int port){ + this.port = port; + return this; + } + + /** + * For the most part, the datagram binds to the local wildcard address (0.0.0.0) which will be decided + * by the kernal. In certain implementations you can supply the host on which to bind here + * @param host - the host to bind the listening socket to + * @return this config + */ + public MqttsnUdpOptions withHost(String host){ + this.host = host; + return this; + } + + /** + * Which local port to use for secure datagram messaging. When running in client mode, set 0 (default) which will mean + * any available local port will be used. + * + * @see {@link MqttsnUdpOptions#DEFAULT_SECURE_PORT} for default gateway value + * @see {@link MqttsnUdpOptions#DEFAULT_LOCAL_CLIENT_PORT} for default client value + * + * @param port - Which local port to use for datagram messaging + * @return this config + */ + public MqttsnUdpOptions withSecurePort(int port){ + this.securePort = port; + return this; + } + + /** + * Set the size of the transport buffer used to receive messages + * + * @see {@link MqttsnUdpOptions#DEFAULT_RECEIVE_BUFFER_SIZE} + * + * @param receiveBuffer - Set the size of the transport buffer used to receive messages + * @return this config + */ + public MqttsnUdpOptions withReceiveBuffer(int receiveBuffer){ + this.receiveBuffer = receiveBuffer; + return this; + } + + /** + * Which local port to use for broadcast messaging + * + * @see {@link MqttsnUdpOptions#DEFAULT_BROADCAST_PORT} for default gateway + * @see {@link MqttsnUdpOptions#DEFAULT_LOCAL_CLIENT_PORT} for default client value + * + * @param multicastPort - Which local port to use for multicast messaging + * @return this config + */ + public MqttsnUdpOptions withBroadcastPort(int multicastPort){ + this.broadcastPort = broadcastPort; + return this; + } + + /** + * Should the runtime listen on the broadcast port for discovery messages + * + * @see {@link MqttsnUdpOptions#DEFAULT_BIND_BROADCAST_LISTENER} for default gateway + * + * @param broadcastListener - Should the runtime listen on the broadcast port for discovery messages + * @return this config + */ + public MqttsnUdpOptions withBindBroadcastListener(boolean broadcastListener){ + this.bindBroadcastListener = broadcastListener; + return this; + } + + public boolean getBindBroadcastListener() { + return bindBroadcastListener; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + public int getReceiveBuffer() { + return receiveBuffer; + } + + public int getBroadcastPort(){ return this.broadcastPort; } + + public int getSecurePort() { + return securePort; + } + + public int getMtu() { return mtu; } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnUdpTransport.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnUdpTransport.java new file mode 100644 index 00000000..fa884811 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/MqttsnUdpTransport.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.net; + +import org.slj.mqtt.sn.impl.AbstractMqttsnUdpTransport; +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.SocketException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.logging.Level; + +/** + * Provides a transport over User Datagram Protocol (UDP). This implementation uses a receiver thread which binds + * onto a Socket in a tight loop blocking on receive with no socket timeout set (0). The receiver thread will simply hand packets + * off to the base receive method who will either pass to the thread pool for handling or handle blocking depending on the + * configuration of the runtime. + * + * The broadcast-receiver when activated runs on it own thread, listening on the broadcast port, updating the registry + * when new contexts are discovered. + */ +public class MqttsnUdpTransport extends AbstractMqttsnUdpTransport { + + private DatagramSocket socket; + private DatagramSocket broadcastSocket; + private Thread receiverThread; + private Thread broadcastThread; + protected boolean running = false; + + public MqttsnUdpTransport(MqttsnUdpOptions udpOptions){ + super(udpOptions); + } + + protected synchronized void bind() throws SocketException { + + running = true; + int bufferSize = options.getReceiveBuffer(); + socket = options.getPort() > 0 ? new DatagramSocket(options.getPort()) : new DatagramSocket(); + //-- by default we do not set SoTimeout (infinite) which will block until recieve + receiverThread = createDatagramServer("mqtt-sn-udp-receiver", bufferSize, socket); + if(options.getBindBroadcastListener() && registry.getOptions().isEnableDiscovery()) { + broadcastSocket = options.getBroadcastPort() > 0 ? new DatagramSocket(options.getBroadcastPort()) : new DatagramSocket(); + broadcastSocket.setBroadcast(true); + broadcastThread = createDatagramServer("mqtt-sn-udp-broadcast", bufferSize, broadcastSocket); + } + } + + protected Thread createDatagramServer(final String threadName, final int bufSize, final DatagramSocket socketIn){ + Thread thread = new Thread(() -> { + logger.log(Level.INFO, String.format("creating udp server [%s] bound to socket [%s] with buffer size [%s], running ? [%s]", threadName, socketIn.getLocalPort(), bufSize, running)); + byte[] buff = new byte[bufSize]; + while(running && !socketIn.isClosed()){ + try { + DatagramPacket p = new DatagramPacket(buff, buff.length); + socketIn.receive(p); + int length = p.getLength(); + + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, "udp receive: length = " + length + ", offset = " + p.getOffset() + ", data = " + p.getData().length); + } + + NetworkAddress address = NetworkAddress.from(p.getPort(), p.getAddress().getHostAddress()); + INetworkContext context = registry.getNetworkRegistry().getContext(address); + if(context == null){ + //-- if the network context does not exist in the registry, a new one is created by the factory - + //- NB: this is NOT auth, this is simply creating a context to which we can respond, auth can + //-- happen during the mqtt-sn context creation, at which point we can talk back to the device + //-- with error packets and the like + context = registry.getContextFactory().createInitialNetworkContext(address); + } + context.setReceivePort(socket.getLocalPort()); + receiveDatagramInternal(context, p); + } catch(Throwable e){ + logger.log(Level.SEVERE, "encountered an error listening for traffic", e); + } finally { + buff = new byte[bufSize]; + } + } + }, threadName); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY + 1); + thread.start(); + return thread; + } + + @Override + public void stop() throws MqttsnException { + super.stop(); + running = false; + socket = null; + broadcastSocket = null; + receiverThread = null; + broadcastThread = null; + } + + @Override + public void writeToTransport(INetworkContext context, ByteBuffer buffer) throws MqttsnException { + try { + byte[] payload = drain(buffer); + DatagramPacket packet = new DatagramPacket(payload, payload.length); + sendDatagramInternal(context, packet); + } catch(Exception e){ + throw new MqttsnException(e); + } + } + + protected void receiveDatagramInternal(INetworkContext context, DatagramPacket packet) throws Exception { + receiveFromTransport(context, wrap(packet.getData(), packet.getLength())); + } + + protected void sendDatagramInternal(INetworkContext context, DatagramPacket packet) throws Exception { + NetworkAddress address = context.getNetworkAddress(); + InetAddress inetAddress = InetAddress.getByName(address.getHostAddress()); + packet.setAddress(inetAddress); + packet.setPort(address.getPort()); + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("sending [%s] byte Datagram (with stack) to [%s] -> [%s]", + packet.getLength(), address, address.getPort()), new Exception("trace")); + } else { + logger.log(Level.INFO, String.format("sending [%s] byte Datagram to [%s] -> [%s]", + packet.getLength(), address, address.getPort())); + } + + socket.send(packet); + } + + @Override + public void broadcast(IMqttsnMessage broadcastMessage) throws MqttsnException { + try { + byte[] arr = registry.getCodec().encode(broadcastMessage); + List broadcastAddresses = registry.getNetworkRegistry().getAllBroadcastAddresses(); + try (DatagramSocket socket = new DatagramSocket()){ + socket.setBroadcast(true); + for(InetAddress address : broadcastAddresses) { + logger.log(Level.FINE, String.format("broadcasting [%s] message to network interface [%s] -> [%s]", + broadcastMessage.getMessageName(), address, options.getBroadcastPort())); + DatagramPacket packet + = new DatagramPacket(arr, arr.length, address, options.getBroadcastPort()); + socket.send(packet); + } + } + } catch(Exception e){ + throw new MqttsnException(e); + } + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkAddress.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkAddress.java new file mode 100644 index 00000000..c2eb4a4a --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkAddress.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.net; + +import java.io.Serializable; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.UnknownHostException; +import java.util.Objects; + +/** + * A network address simply represents a host name or IP address and port combination. + * Represents a remote ipv4 or ipv6 location. Regardless of the supplied format, the address will + * be stored in its canonical form. + */ +public class NetworkAddress implements Serializable { + + private final String hostAddress; + private final int port; + + /** + * Create a new network address from the port and address supplied. + * @param port - The port on which the remote is bound + * @param hostAddress - A valid ipv4, ipv6 or host address. Where a name is supplied, + * an attempt will be made to eagerly resolve it so unknown hosts are derived eagerly. + * @throws UnknownHostException - no host could be found + */ + public NetworkAddress(int port, String hostAddress) throws UnknownHostException { + this.hostAddress = InetAddress.getByName(hostAddress).getHostAddress(); + if(port < 0 || port > 65535) throw new IllegalArgumentException("port must be in range 0 <= port <= 65535"); + this.port = port; + } + + public String getHostAddress() { + return hostAddress; + } + + public int getPort() { + return port; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NetworkAddress that = (NetworkAddress) o; + return port == that.port && + Objects.equals(hostAddress, that.hostAddress); + } + + @Override + public int hashCode() { + return Objects.hash(hostAddress, port); + } + + public InetSocketAddress toSocketAddress(){ + return InetSocketAddress.createUnresolved(hostAddress, port); + } + + public static NetworkAddress from(InetSocketAddress address) throws UnknownHostException { + return NetworkAddress.from(address.getPort(), address.getAddress().getHostAddress()); + } + + public static NetworkAddress from(int port, String hostAddress) throws UnknownHostException { + return new NetworkAddress(port, hostAddress); + } + + /** + * Convenience method to obtain a network address to local host loopback (127.0.0.1) on the port specified. + * @param port - the local port + * @return the NetworkAddress + */ + public static NetworkAddress localhost(int port) { + try { + return new NetworkAddress(port, "127.0.0.1"); + } catch(UnknownHostException e){ + throw new RuntimeException(e); + } + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("SocketAddress ["); + sb.append("address='").append(hostAddress).append('\''); + sb.append(", port=").append(port); + sb.append(']'); + return sb.toString(); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkAddressRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkAddressRegistry.java new file mode 100644 index 00000000..e2121184 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkAddressRegistry.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.net; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.spi.INetworkAddressRegistry; +import org.slj.mqtt.sn.spi.MqttsnRuntimeException; +import org.slj.mqtt.sn.spi.NetworkRegistryException; + +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +public class NetworkAddressRegistry implements INetworkAddressRegistry { + + static Logger logger = Logger.getLogger(NetworkAddressRegistry.class.getName()); + + final protected Map networkRegistry; + final protected Map mqttsnContextRegistry; + final protected Map networkContextRegistry; + + final private Object mutex = new Object(); + + public NetworkAddressRegistry(int initialCapacity){ + networkRegistry = Collections.synchronizedMap(new HashMap<>(initialCapacity)); + mqttsnContextRegistry = Collections.synchronizedMap(new HashMap<>(initialCapacity)); + networkContextRegistry = Collections.synchronizedMap(new HashMap<>(initialCapacity)); + } + + @Override + public INetworkContext getContext(NetworkAddress address) throws NetworkRegistryException { + INetworkContext context = networkRegistry.get(address); + logger.log(Level.FINE, String.format("getting network context from RAM registry by address [%s] -> [%s]", address, context)); + return context; + } + + @Override + public INetworkContext getContext(IMqttsnContext sessionContext) { + INetworkContext context = mqttsnContextRegistry.get(sessionContext); + if(context == null) + throw new MqttsnRuntimeException("unable to get network route for session " + sessionContext); + logger.log(Level.FINE, String.format("getting network context from RAM registry by session [%s] -> [%s]", sessionContext, context)); + return context; + } + + @Override + public IMqttsnContext getSessionContext(INetworkContext networkContext){ + IMqttsnContext context = networkContextRegistry.get(networkContext); + if(context == null) + throw new MqttsnRuntimeException("unable to get session context for network route " + networkContext); + logger.log(Level.FINE, String.format("getting session context from RAM registry network route [%s] -> [%s]", networkContext, context)); + return context; + } + + @Override + public Optional first() throws NetworkRegistryException { + Iterator itr = networkRegistry.keySet().iterator(); + synchronized (networkRegistry){ + while(itr.hasNext()){ + NetworkAddress address = itr.next(); + INetworkContext c = networkRegistry.get(address); + return Optional.of(c); + } + } + return Optional.empty(); + } + + @Override + public Optional findForClientId(String clientId) { + if(clientId == null) return null; + synchronized (mqttsnContextRegistry){ + return mqttsnContextRegistry.keySet().stream(). + filter(s -> s.getId() != null). + filter(s -> s.getId().equals(clientId)).findFirst(); + } + } + + @Override + public void putContext(INetworkContext context) { + networkRegistry.put(context.getNetworkAddress(), context); + logger.log(Level.INFO, String.format("adding network context to RAM registry - [%s]", context)); + synchronized(mutex){ + mutex.notifyAll(); + } + } + + @Override + public void bindContexts(INetworkContext context, IMqttsnContext sessionContext) { + logger.log(Level.INFO, String.format("binding network to session contexts - [%s] -> [%s]", context, sessionContext)); + mqttsnContextRegistry.put(sessionContext, context); + networkContextRegistry.put(context, sessionContext); + putContext(context); + } + + @Override + public boolean hasBoundSessionContext(INetworkContext context){ + IMqttsnContext c = networkContextRegistry.get(context); + return c != null; + } + + @Override + public boolean removeExistingClientId(String clientId){ + Iterator itr = mqttsnContextRegistry.keySet().iterator(); + synchronized (mqttsnContextRegistry) { + while (itr.hasNext()) { + IMqttsnContext m = itr.next(); + if(m.getId().equals(clientId)){ + INetworkContext c = mqttsnContextRegistry.get(m); + itr.remove(); + networkRegistry.remove(c.getNetworkAddress()); + networkContextRegistry.remove(c); + logger.log(Level.INFO, String.format("removing network,session & address from RAM registry - [%s]", clientId)); + return true; + } + } + } + + return false; + } + + public Optional waitForContext(int time, TimeUnit unit) throws NetworkRegistryException, InterruptedException { + synchronized(mutex){ + try { + while(networkRegistry.isEmpty()){ + mutex.wait(TimeUnit.MILLISECONDS.convert(time, unit)); + } + return first(); + } catch(InterruptedException e){ + Thread.currentThread().interrupt(); + throw e; + } + } + } + + public Iterator iterator() throws NetworkRegistryException { + return networkRegistry.values().iterator(); + } + + public List getAllBroadcastAddresses() throws NetworkRegistryException { + try { + List l = new ArrayList<>(); + Enumeration interfaces + = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface networkInterface = interfaces.nextElement(); + if (networkInterface.isLoopback() || + !networkInterface.isUp()) { + continue; + } + networkInterface.getInterfaceAddresses().stream() + .map(a -> a.getBroadcast()) + .filter(Objects::nonNull) + .forEach(l::add); + } + return l; + } catch(SocketException e){ + throw new NetworkRegistryException(e); + } + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkContext.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkContext.java new file mode 100644 index 00000000..2440e995 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/net/NetworkContext.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.net; + +import org.slj.mqtt.sn.model.AbstractContextObject; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.INetworkContext; + +public class NetworkContext extends AbstractContextObject implements INetworkContext { + + protected NetworkAddress networkAddress; + protected int receivePort; + + public NetworkContext(){} + + public NetworkContext(NetworkAddress networkAddress){ + this.networkAddress = networkAddress; + } + + public int getReceivePort() { + return receivePort; + } + + public void setReceivePort(int receivePort) { + this.receivePort = receivePort; + } + + @Override + public NetworkAddress getNetworkAddress() { + return networkAddress; + } + + public void setNetworkAddress(NetworkAddress networkAddress) { + this.networkAddress = networkAddress; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NetworkContext that = (NetworkContext) o; + return networkAddress.equals(that.networkAddress); + } + + @Override + public int hashCode() { + return networkAddress.hashCode(); + } + + @Override + public String toString() { + return "NetworkContext{" + + "networkAddress=" + networkAddress + + ", receivePort=" + receivePort + + "," + super.toString() + + '}'; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnConnectionStateListener.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnConnectionStateListener.java new file mode 100644 index 00000000..aab1ba2b --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnConnectionStateListener.java @@ -0,0 +1,85 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; + +/** + * Bind in a listener to be notified of CONNECTION events from the various sub-systems + */ +public interface IMqttsnConnectionStateListener { + + /** + * The context has successfully exchanged CONNECT messages with the remote. + * Generated by the message handler + * @param context - the origin context + */ + void notifyConnected(IMqttsnContext context); + + /** + * An unsolicited DISCONNECT message was received from the context + * Generated by the message handler + * @param context - the origin context + */ + void notifyRemoteDisconnect(IMqttsnContext context); + + /** + * Where applicable, the runtime has not exchanged any active messages (CONNECT, PUBLISH, SUBSCRIBE, UNSUBSCRIBE) + * with the remote. + * + * NB: This is different from keepAlive, and is a timing around ACTIVE rather than PASSIVE messages. ie. excluding PING + * + * Generated by the message state service + * + * @param context - the origin context + */ + void notifyActiveTimeout(IMqttsnContext context); + + /** + * An error occurred in the local runtime which caused an unsolicited DISCONNECT operation to be sent + * to the remote + * + * NB: this is only called for DISCONNECT operations not created by the application + * + * Generated by the message handler + * + * @param context - the origin context + * @param t - the exception that caused the local error + */ + void notifyLocalDisconnect(IMqttsnContext context, Throwable t); + + /** + * When stateful connections are held by the transport layer (ie. TCP sockets) + * when connections are lost (e.g. socket timeout on read, EOF received, write IO) + * the method can propogate the event up to the application + * @param context - the origin context + * @param t - the exception that caused the connection loss + */ + void notifyConnectionLost(IMqttsnContext context, Throwable t); + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnContextFactory.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnContextFactory.java new file mode 100644 index 00000000..c6e3bc7a --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnContextFactory.java @@ -0,0 +1,54 @@ +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.model.MqttsnContext; +import org.slj.mqtt.sn.net.NetworkAddress; +import org.slj.mqtt.sn.net.NetworkContext; + +/** + * A context factory deals with the initial construction of the context objects which identity + * the remote connection to the application. There are 2 types of context; a {@link NetworkContext} + * and a {@link MqttsnContext}. The network context identifies where (the network location) the identity + * resides and the mqttsn-context identifies who the context is (generally this is the CliendId or GatewayId of + * the connected resource). + * + * A {@link NetworkContext} can exist in isolation without an associated {@link MqttsnContext}, during a CONNECT attempt + * (when the context has yet to be established), or during a failed CONNECTion. An application context cannot exist without + * a network context. + * + * You can provide your own implementation, if you wish to wrap or provide your own extending context implementation + * to wrap custom User objects, for example. + * + */ +public interface IMqttsnContextFactory { + + /** + * When no network context can be found in the registry from the associated {@link NetworkAddress}, + * the factrory is called to create a new instance from the address supplied. + * @param address - the source address from which traffic has been received. + * @return - the new instance of a network context bound to the address supplied + * @throws MqttsnException - an error has occurred + */ + INetworkContext createInitialNetworkContext(NetworkAddress address) throws MqttsnException; + + /** + * No application existed for the network context OR a new clientId was detected, so we + * should create a new application context pinned to the network context supplied. + * @param context - The source network context + * @param clientId - The clientId which was supplied by the CONNECT packet + * @return the new instance of the application context coupled to the network context + * @throws MqttsnSecurityException - The supplied clientId was not allowed on the gateway + */ + IMqttsnContext createInitialApplicationContext(INetworkContext context, String clientId) throws MqttsnSecurityException; + + /** + * No application existed for the network context OR a new clientId was detected, so we + * should create a new application context pinned to the network context supplied. + * @param context - The source network context + * @return the new instance of the application context coupled to the network context + * @throws MqttsnSecurityException - The supplied clientId was not allowed on the gateway + */ + IMqttsnContext createTemporaryApplicationContext(INetworkContext context) throws MqttsnSecurityException; + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnInstrumentationProvider.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnInstrumentationProvider.java new file mode 100644 index 00000000..b19a75d1 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnInstrumentationProvider.java @@ -0,0 +1,15 @@ +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.utils.StringTable; + +/** + * When called, the provider is a source of instrumentation which will be output by the runtime + */ +public interface IMqttsnInstrumentationProvider { + + /** + * Provide a snapshot at the point of invocation + * @return A {@link StringTable} containing details of the current runtime + */ + StringTable provideInstrumentation(); +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageHandler.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageHandler.java new file mode 100644 index 00000000..41d70b68 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageHandler.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.INetworkContext; + +/** + * The message handler is delegated to by the transport layer and its job is to process + * inbound messages and marshall into other controllers to manage state lifecycle, authentication, permission + * etc. + * + * It is directly responsible for creating response messages and sending them back to the transport layer + */ +public interface IMqttsnMessageHandler extends IMqttsnService{ + + + /** + * Determine if a network location is considered valid for a publish -1 + * @param context - the network context from where a message originated + * @return - true if we consider the context authorised, false otherwise + * @throws MqttsnException - an error occurred + */ + boolean temporaryAuthorizeContext(INetworkContext context) + throws MqttsnException; + + + /** + * Determine if a network location and clientId are considered authorised + * @param context - the network context from where a message originated + * @param clientId - the clientId passed in during a CONNECT procedure + * @return - true if we consider the context authorised, false otherwise + * @throws MqttsnException - an error occurred + */ + boolean authorizeContext(INetworkContext context, String clientId) + throws MqttsnException; + + void receiveMessage(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException; + + boolean canHandle(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException; + + boolean validResponse(IMqttsnMessage request, IMqttsnMessage response); + + boolean requiresResponse(IMqttsnMessage message); + + boolean isTerminalMessage(IMqttsnMessage message); + + boolean isPartOfOriginatingMessage(IMqttsnMessage message); + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageQueue.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageQueue.java new file mode 100644 index 00000000..efdd5449 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageQueue.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.MqttsnQueueAcceptException; +import org.slj.mqtt.sn.model.MqttsnWaitToken; +import org.slj.mqtt.sn.model.QueuedPublishMessage; + +import java.util.Iterator; +import java.util.List; + +/** + * Queue implementation to store messages destined to and from gateways and clients. Queues will be flushed acccording + * to the session semantics defined during CONNECT. + * + * Ideally the queue should be implemented to support FIFO where possible. + */ +public interface IMqttsnMessageQueue extends IMqttsnRegistry { + + /** + * List the contexts which are present within this queue. Each context represents a client or gateway Queue of which + * there will be many when run in gateway mode. + * + * @return - An iterator over the contexts for which a queue is presently held + * @throws MqttsnException - an error occurred + */ + Iterator listContexts() throws MqttsnException; + + /** + * Return the size of the queue for a given context + * @param context - the context whose queue youd like to query + * @return - the size of the queue for a given context + * @throws MqttsnException - an error occurred + */ + int size(IMqttsnContext context) throws MqttsnException; + + /** + * Offer the queue or a context a new message to add to the tail. + * @param context - the context whose queue youd like to append + * @param message - the message metadata to queue + * @return - token if the message was added + * @throws MqttsnException - an error occurred, most likely the queue was full + */ + MqttsnWaitToken offer(IMqttsnContext context, QueuedPublishMessage message) throws MqttsnException, MqttsnQueueAcceptException; + + /** + * Pop a message from the head of the queue. This removes and returns the message at the Head. + * @param context - the context whose queue youd like to query + * @return the message from the head of the queue or NULL if the queue is empty + * @throws MqttsnException - an error occurred + */ + QueuedPublishMessage pop(IMqttsnContext context) throws MqttsnException; + + /** + * Peek at the message on the head of the queue without removing it + * @param context - the context whose queue youd like to query + * @return the message from the head of the queue or NULL if the queue is empty + * @throws MqttsnException - an error occurred + */ + QueuedPublishMessage peek(IMqttsnContext context) throws MqttsnException; + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageQueueProcessor.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageQueueProcessor.java new file mode 100644 index 00000000..f0b2e008 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageQueueProcessor.java @@ -0,0 +1,28 @@ +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; + +/** + * The job of the queue processor is to (when requested) interact with a remote contexts' queue, processing + * the next message from the HEAD of the queue, handling any topic registration, session state, marking + * messages inflight and finally returning an indicator as to what should happen when the processing of + * the next message is complete. Upon dealing with the next message, whether successful or not, the processor + * needs to return an indiction; + * + * REMOVE_PROCESS - The queue is empty and the context no longer needs further processing + * BACKOFF_PROCESS - The queue is not empty, come back after a backend to try again. Repeating this return type for the same context + * will yield an exponential backoff + * REPROCESS (continue) - The queue is not empty, (where possible) call me back immediately to process again + * + */ +public interface IMqttsnMessageQueueProcessor + extends IMqttsnService { + + enum RESULT { + REMOVE_PROCESS, + BACKOFF_PROCESS, + REPROCESS + } + + RESULT process(IMqttsnContext context) throws MqttsnException ; +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageRegistry.java new file mode 100644 index 00000000..13d42992 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageRegistry.java @@ -0,0 +1,25 @@ +package org.slj.mqtt.sn.spi; + +import java.util.Date; +import java.util.UUID; + +/** + * The message registry is a normalised view of transiting messages, it context the raw payload of publish operations + * so light weight references to the payload can exist in multiple storage systems without duplication of data. + * For example, when running in gateway mode, the same message my reside in queues for numerous devices which are + * in different connection states. We should not store payloads N times in this case. + * + * The lookup is a simple UUID -> byte[] relationship. It is up to the registry implementation to decide how to store + * this data. + * + */ +public interface IMqttsnMessageRegistry extends IMqttsnRegistry{ + + void tidy() throws MqttsnException ; + + UUID add(byte[] data, boolean removeAfterRead) throws MqttsnException ; + + UUID add(byte[] data, Date expires) throws MqttsnException; + + byte[] get(UUID messageId) throws MqttsnException; +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageStateService.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageStateService.java new file mode 100644 index 00000000..4469b0b8 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnMessageStateService.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.*; + +import java.util.Date; +import java.util.Optional; + +/** + * The state service is responsible for sending messages and processing received messages. It maintains state + * and tracks messages in and out and their successful acknowledgement (or not). + * + * The message handling layer will call into the state service with messages it has received, and the queue processor + * will use the state service to dispatch new outbound publish messages. + */ +public interface IMqttsnMessageStateService extends IMqttsnRegistry { + + /** + * Dispatch a new message to the transport layer, binding it to the state service en route for tracking. + * @param context - the recipient of the message + * @param message - the wire message to send + * @throws MqttsnException + */ + MqttsnWaitToken sendMessage(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException; + + /** + * Dispatch a new message to the transport layer, binding it to the state service en route for tracking. + * @param context + * @param queuedPublishMessage - reference to the queues message who originated this send (when its of type Publish); + * @throws MqttsnException + */ + MqttsnWaitToken sendMessage(IMqttsnContext context, TopicInfo info, QueuedPublishMessage queuedPublishMessage) throws MqttsnException; + + + /** + * Notify into the state service that a new message has arrived from the transport layer. + * @param context - the context from which the message was received + * @param message - the message received from the transport layer + * @return the messsage (if any) that was confirmed by the receipt of the inbound message + * @throws MqttsnException + */ + IMqttsnMessage notifyMessageReceived(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException; + + /** + * Join the message sent in waiting for the subsequent confirmation if it needs one + * @param context - The context to whom you are speaking + * @return An optional which will contain either the confirmation message associated with the + * message supplied OR optional NULL where the message does not require a reply + * @throws MqttsnExpectationFailedException - When no confirmation was recieved in the time period + * specified by the runtime options + */ + Optional waitForCompletion(IMqttsnContext context, MqttsnWaitToken token) throws MqttsnExpectationFailedException; + + /** + * Join the message sent in waiting for the subsequent confirmation if it needs one + * @param context - The context to whom you are speaking + * @param customWaitTime - a custom time to wait for the token to complete + * @return An optional which will contain either the confirmation message associated with the + * message supplied OR optional NULL where the message does not require a reply + * @throws MqttsnExpectationFailedException - When no confirmation was recieved in the time period + * specified by the runtime options + */ + Optional waitForCompletion(IMqttsnContext context, MqttsnWaitToken token, int customWaitTime) throws MqttsnExpectationFailedException; + + /** + * If a context has any messages inflight, notify the state service to clear them up, either requeuing or discarding + * according to configuration + * @param context - The context to whom you are speaking + * @throws MqttsnException - an error has occurred + */ + void clearInflight(IMqttsnContext context) throws MqttsnException ; + + /** + * Ccount the number of message inflight for a given direction and context + * @param context - The context to whom you are speaking + * @param direction - Inflight has 2 channels, inbound and outbound per the spec. Count in which direction. + * @return The number of messages inflight in a given direction + * @throws MqttsnException - an error has occurred + */ + int countInflight(IMqttsnContext context, InflightMessage.DIRECTION direction) throws MqttsnException ; + + /** + * Remove a specific message from inflight using its messageId, returning the message. + * If no message exists inflight with the corresponding ID, null will be returned. + * @param context - The context to whom you are speaking + * @param msgId - The message id to return + * @return - the corresponding message or NULL if not found + * @throws MqttsnException - an error has occurred + */ + InflightMessage removeInflight(IMqttsnContext context, int msgId) throws MqttsnException ; + + /** + * According to the state rules, are we in a position to send PUBLISH messages to the given context + * @param context - The context to whom you are speaking + * @return true if the state service think we're ok to send PUBLISHES to the device, else false + * @throws MqttsnException - an error has occurred + */ + boolean canSend(IMqttsnContext context) throws MqttsnException ; + + /** + * Mark a context active to have its outbound queue processed + * @param context - The context whose queue should be processed + * @throws MqttsnException - an error has occurred + */ + void scheduleFlush(IMqttsnContext context) throws MqttsnException ; + + /** + * UnMark a context active to have its outbound queue processed + * @param context - The context whose queue should be processed + * @throws MqttsnException - an error has occurred + */ + void unscheduleFlush(IMqttsnContext context) throws MqttsnException ; + + /** + * Tracks the point at which the last message was SENT to the context + * @param context - The context to which the message was sent + * @return Long representing the point at which the last message was SENT to the context + * @throws MqttsnException + */ + Long getMessageLastSentToContext(IMqttsnContext context) throws MqttsnException ; + + /** + * Tracks the point at which the last message was RECEIVED from the context + * @param context - The context to which the message was sent + * @return Long representing the point at which the last message was SENT to the context + * @throws MqttsnException + */ + Long getMessageLastReceivedFromContext(IMqttsnContext context) throws MqttsnException ; + + /** + * Tracks the point at which a message considered ACTIVE was last SENT or RECEIVIED from the context + * @param context - The context to which the message was sent + * @return Tracks the point at which a message considered ACTIVE was last SENT or RECEIVIED from the context + * @throws MqttsnException + */ +// Long getContextLastActive(IMqttsnContext context) throws MqttsnException ; +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPermissionService.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPermissionService.java new file mode 100644 index 00000000..f4d2b106 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPermissionService.java @@ -0,0 +1,52 @@ +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; + +/** + * Optional - when installed it will be consulted to determine whether a remote context can perform certain + * operations; + * + * CONNECT with the given clientId + * SUBSCRIBE to a given topicPath + * Granted Maximum Subscription Levels + * Eligibility to publish to a given path & size + */ +public interface IMqttsnPermissionService { + + /** + * Is a client allowed to CONNECT successfully. + * @param context - the context who would like to CONNECT + * @param clientId - the client Id they provided in the CONNECT dialog + * @return true if the client is allowed to CONNECT (yielding CONNACK ok) or false if not allowed (yielding CONNACK error) + * @throws MqttsnException an error occurred + */ + boolean allowConnect(IMqttsnContext context, String clientId) throws MqttsnException; + + /** + * Is a client allowed to SUBSCRIBE to a given topic path + * @param context - the context who would like to SUBSCRIBE + * @param topicPath - the topic path to subscribe to + * @return true if the client is allowed to SUBSCRIBE (yielding SUBACK ok) or false if not allowed (yielding SUBACK error) + * @throws MqttsnException an error occurred + */ + boolean allowedToSubscribe(IMqttsnContext context, String topicPath) throws MqttsnException; + + /** + * What is the maximum granted QoS allowed on a given topicPath. + * @param context - the context who would like to SUBSCRIBE + * @param topicPath - the topic path to subscribe to + * @return one of 0,1,2 matching the maximum QoS that can be granted for this subscription + * @throws MqttsnException an error occurred + */ + int allowedMaximumQoS(IMqttsnContext context, String topicPath) throws MqttsnException; + + /** + * Is the context allowed to publish to the given topicPath. + * @param context - the context who would like to PUBLISH + * @param topicPath - the topic path to publish to + * @param size - size of the data to be published + * @return true if the client is allowed to PUBLISH (yielding PUBLISH normal lifecycle) or false if not allowed (yielding PUBACK error) + * @throws MqttsnException an error occurred + */ + boolean allowedToPublish(IMqttsnContext context, String topicPath, int size, int QoS) throws MqttsnException; +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishFailureListener.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishFailureListener.java new file mode 100644 index 00000000..72770f57 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishFailureListener.java @@ -0,0 +1,38 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; + +import java.util.UUID; + +public interface IMqttsnPublishFailureListener { + + void sendFailure(IMqttsnContext context, UUID messageId, String topicName, int QoS, byte[] data, int retryCount); + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishReceivedListener.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishReceivedListener.java new file mode 100644 index 00000000..157c5534 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishReceivedListener.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; + +public interface IMqttsnPublishReceivedListener { + + void receive(IMqttsnContext context, String topicName, int QoS, byte[] data); +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishSentListener.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishSentListener.java new file mode 100644 index 00000000..4ca5f604 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnPublishSentListener.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; + +import java.util.UUID; + +public interface IMqttsnPublishSentListener { + + void sent(IMqttsnContext context, UUID messageId, String topicName, int QoS, byte[] data); +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnQueueProcessorStateService.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnQueueProcessorStateService.java new file mode 100644 index 00000000..c3088865 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnQueueProcessorStateService.java @@ -0,0 +1,28 @@ +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; + +/** + * NOTE: this is optional + * When contributed, this controller is used by the queue processor to check if the application is in a fit state to + * offload messages to a remote (gateway or client), and is called back by the queue processor to be notified of + * the queue being empty after having been flushed. + */ +public interface IMqttsnQueueProcessorStateService { + + /** + * The application can determine if its in a position to send publish messages to the remote context + * @param context - The context who is to receive messages + * @return - Is the context in a position to receive message + * @throws MqttsnException - An error has occurred + */ + boolean canReceive(IMqttsnContext context) throws MqttsnException; + + /** + * Having flushed >= 1 messages to a context, this method will be notified to allow any final + * session related actions to be taken + * @return - Is the context in a position to receive message + * @throws MqttsnException - An error has occurred + */ + void queueEmpty(IMqttsnContext context) throws MqttsnException; +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnRegistry.java new file mode 100644 index 00000000..7223b2f0 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnRegistry.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; + +public interface IMqttsnRegistry extends IMqttsnService { + + void clear(IMqttsnContext context) throws MqttsnException; + + void clearAll() throws MqttsnException; + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnRuntimeRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnRuntimeRegistry.java new file mode 100644 index 00000000..ec80bcfe --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnRuntimeRegistry.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntime; +import org.slj.mqtt.sn.model.MqttsnOptions; + +import java.util.List; + +public interface IMqttsnRuntimeRegistry { + + void setRuntime(AbstractMqttsnRuntime runtime); + + void init(); + + MqttsnOptions getOptions(); + + AbstractMqttsnRuntime getRuntime(); + + IMqttsnTransport getTransport(); + + IMqttsnCodec getCodec(); + + IMqttsnMessageQueue getMessageQueue(); + + IMqttsnMessageFactory getMessageFactory(); + + IMqttsnMessageHandler getMessageHandler(); + + IMqttsnMessageStateService getMessageStateService(); + + INetworkAddressRegistry getNetworkRegistry(); + + IMqttsnTopicRegistry getTopicRegistry(); + + IMqttsnSubscriptionRegistry getSubscriptionRegistry(); + + IMqttsnContextFactory getContextFactory(); + + IMqttsnMessageQueueProcessor getQueueProcessor(); + + IMqttsnQueueProcessorStateService getQueueProcessorStateCheckService(); + + IMqttsnMessageRegistry getMessageRegistry(); + + IMqttsnPermissionService getPermissionService(); + + List getTrafficListeners(); +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnService.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnService.java new file mode 100644 index 00000000..1858e649 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnService.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +public interface IMqttsnService { + + void start(T runtime) throws MqttsnException; + + void stop() throws MqttsnException; + + boolean running(); +} \ No newline at end of file diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnSubscriptionRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnSubscriptionRegistry.java new file mode 100644 index 00000000..74232841 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnSubscriptionRegistry.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.Subscription; + +import java.util.List; +import java.util.Set; + +/** + * The subscription registry maintains a list of subscriptions against the remote context. On the gateway this + * is used to determine which clients are subscribed to which topics to enable outbound delivery. In client + * mode it tracks the subscriptions a client presently holds. + */ +public interface IMqttsnSubscriptionRegistry extends IMqttsnRegistry { + + /** + * Which QoS is a subscription held at + * @param context - the remote context who owns the subscription + * @param topicPath - the full clear text topicPath for the subscription e.g. foo/bar + * @return the QoS at which the subscription is held (0,1,2) + * @throws MqttsnException - an error occurred + */ + int getQos(IMqttsnContext context, String topicPath) throws MqttsnException; + + /** + * Create a new subscription for the context, or update the subscription if it already + * existed + * @param context - the remote context who owns the subscription + * @param topicPath - the full clear text topicPath for the subscription e.g. foo/bar + * @return the QoS at which the subscription is to be held (0,1,2) + * @return true if a NEW subscription or was created, false if one already existed (and was updated) + * @throws MqttsnException - an error occurred + */ + boolean subscribe(IMqttsnContext context, String topicPath, int QoS) throws MqttsnException; + + /** + * Remove and existing subscription for the context + * @param context - the remote context who owns the subscription + * @param topicPath - the full clear text topicPath for the subscription e.g. foo/bar + * @return true if a subscription was removed, false if one didnt exist + * @throws MqttsnException - an error occurred + */ + boolean unsubscribe(IMqttsnContext context, String topicPath) throws MqttsnException; + + /** + * This is called upon receipt of a message being received by a BROKER which necessitated expansion + * onto mutliple client queues. (Message expansion). + * + * @param topicPath - the full clear text topicPath for the subscription e.g. foo/bar + * @return a list of context which hold valid subscriptions for the supplied topic (including wildcard matching) + * @throws MqttsnException + */ + List matches(String topicPath) throws MqttsnException ; + + + /** + * A set of all the tracked subscriptions for the context + * + * @param context - the remote context who owns the subscriptions + * @return a set of subscriptions to which the context is subscribed + * @throws MqttsnException + */ + Set readSubscriptions(IMqttsnContext context) throws MqttsnException ; +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTopicRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTopicRegistry.java new file mode 100644 index 00000000..255fc0ba --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTopicRegistry.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.TopicInfo; + +/** + * The topic registry is responsible for tracking, storing and determining the correct alias + * to use for a given remote context and topic combination. The topic registry will be cleared + * according to session lifecycle rules. + */ +public interface IMqttsnTopicRegistry extends IMqttsnRegistry { + + TopicInfo normalize(byte topicIdType, byte[] topicData, boolean normalAsLong) throws MqttsnException; + + TopicInfo register(IMqttsnContext context, String topicPath) throws MqttsnException; + + void register(IMqttsnContext context, String topicPath, int alias) throws MqttsnException; + + boolean registered(IMqttsnContext context, String topicPath) throws MqttsnException; + + TopicInfo lookup(IMqttsnContext context, String topicPath) throws MqttsnException; + + TopicInfo lookup(IMqttsnContext context, String topicPath, boolean confirmedOnly) throws MqttsnException; + + String topicPath(IMqttsnContext context, TopicInfo topicInfo, boolean considerContext) throws MqttsnException ; + + //-- lookup specific parts of the registry + Integer lookupRegistered(IMqttsnContext context, String topicPath, boolean confirmedOnly) throws MqttsnException; + + Integer lookupRegistered(IMqttsnContext context, String topicPath) throws MqttsnException; + + String lookupRegistered(IMqttsnContext context, int topicAlias) throws MqttsnException; + + Integer lookupPredefined(IMqttsnContext context, String topicPath) throws MqttsnException; + + String lookupPredefined(IMqttsnContext context, int topicAlias) throws MqttsnException; + + void clear(IMqttsnContext context, boolean hardClear) throws MqttsnException; + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTrafficListener.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTrafficListener.java new file mode 100644 index 00000000..bb7abf14 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTrafficListener.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.INetworkContext; + +/** + * Traffic listeners can contributed to the runtime to be notified of any traffic processed by + * the transport layer. Listeners are not able to affect the traffic in transit or the business + * logic executed during the course of the traffic, they are merely observers. + * + * The listeners ~may be notified asynchronously from the application, and thus they cannot be relied + * upon to give an absolute timeline of traffic. + * + */ +public interface IMqttsnTrafficListener { + + /** + * Traffic has been successfully sent by the transport layer to the context + * @param context - the context from which the transport originated + * @param data - the raw data + * @param message - the data that was sent/received + */ + void trafficSent(INetworkContext context, byte[] data, IMqttsnMessage message); + + /** + * Traffic has been received by the transport layer + * @param context - the context from which the transport originated + * @param data - the raw data + * @param message - the data that was sent/received + */ + void trafficReceived(INetworkContext context, byte[] data, IMqttsnMessage message); + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTransport.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTransport.java new file mode 100644 index 00000000..8cb0f28d --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/IMqttsnTransport.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.INetworkContext; + +import java.nio.ByteBuffer; + +/** + * The transport layer is responsible for managing the receiving and sending of messages over some connection. + * No session is assumed by the application and the connection is considered stateless at this point. + * It is envisaged implementations will include UDP (with and without DTLS), TCP-IP (with and without TLS), + * BLE and ZigBee. + * + * Please refer to {@link org.slj.mqtt.sn.impl.AbstractMqttsnTransport} and sub-class your own implementations + * or choose an existing implementation out of the box. + * + * @see {@link org.slj.mqtt.sn.net.MqttsnUdpTransport} for an example of an out of the box implementation. + */ +public interface IMqttsnTransport { + + void receiveFromTransport(INetworkContext context, ByteBuffer buffer); + + void writeToTransport(INetworkContext context, IMqttsnMessage message) throws MqttsnException ; + + void broadcast(IMqttsnMessage message) throws MqttsnException ; + + void connectionLost(INetworkContext context, Throwable t); + + boolean restartOnLoss(); + +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/INetworkAddressRegistry.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/INetworkAddressRegistry.java new file mode 100644 index 00000000..e6f8b55a --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/INetworkAddressRegistry.java @@ -0,0 +1,43 @@ +package org.slj.mqtt.sn.spi; + +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.INetworkContext; +import org.slj.mqtt.sn.net.NetworkAddress; + +import java.net.InetAddress; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +/** + * The network registry maintains a list of known network contexts against a remote address ({@link NetworkAddress}). + * It exposes functionality to wait for discovered contexts as well as returning a list of valid broadcast addresses. + */ +public interface INetworkAddressRegistry { + + INetworkContext getContext(NetworkAddress address) throws NetworkRegistryException ; + + INetworkContext getContext(IMqttsnContext sessionContext); + + IMqttsnContext getSessionContext(INetworkContext networkContext); + + Optional first() throws NetworkRegistryException ; + + void putContext(INetworkContext context) throws NetworkRegistryException ; + + void bindContexts(INetworkContext context, IMqttsnContext sessionContext); + + Optional waitForContext(int time, TimeUnit unit) throws InterruptedException, NetworkRegistryException; + + List getAllBroadcastAddresses() throws NetworkRegistryException ; + + Iterator iterator() throws NetworkRegistryException ; + + boolean removeExistingClientId(String clientId); + + boolean hasBoundSessionContext(INetworkContext context); + + Optional findForClientId(String clientId); +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnException.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnException.java new file mode 100644 index 00000000..f63e5526 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnException.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +public class MqttsnException extends Exception { + + public MqttsnException() { + } + + public MqttsnException(String message) { + super(message); + } + + public MqttsnException(String message, Throwable cause) { + super(message, cause); + } + + public MqttsnException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnExpectationFailedException.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnExpectationFailedException.java new file mode 100644 index 00000000..62f83b65 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnExpectationFailedException.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +public class MqttsnExpectationFailedException extends MqttsnException { + + public MqttsnExpectationFailedException() { + } + + public MqttsnExpectationFailedException(String message) { + super(message); + } + + public MqttsnExpectationFailedException(String message, Throwable cause) { + super(message, cause); + } + + public MqttsnExpectationFailedException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnRuntimeException.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnRuntimeException.java new file mode 100644 index 00000000..a4b06b88 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnRuntimeException.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +public class MqttsnRuntimeException extends RuntimeException { + + public MqttsnRuntimeException() { + } + + public MqttsnRuntimeException(String message) { + super(message); + } + + public MqttsnRuntimeException(String message, Throwable cause) { + super(message, cause); + } + + public MqttsnRuntimeException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnSecurityException.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnSecurityException.java new file mode 100644 index 00000000..753596a4 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnSecurityException.java @@ -0,0 +1,19 @@ +package org.slj.mqtt.sn.spi; + +public class MqttsnSecurityException extends SecurityException { + + public MqttsnSecurityException() { + } + + public MqttsnSecurityException(String s) { + super(s); + } + + public MqttsnSecurityException(String message, Throwable cause) { + super(message, cause); + } + + public MqttsnSecurityException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnService.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnService.java new file mode 100644 index 00000000..231a3ca8 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/MqttsnService.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.spi; + +import java.util.logging.Logger; + +public abstract class MqttsnService implements IMqttsnService { + + protected final Logger logger = Logger.getLogger(getClass().getName()); + protected T registry; + protected volatile boolean running = false; + + public MqttsnService(){ + } + + public void start(T runtime) throws MqttsnException { + this.registry = runtime; + running = true; + } + + public void stop() throws MqttsnException { + running = false; + } + + protected T getRegistry(){ + return registry; + } + + public boolean running(){ + return running; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/NetworkRegistryException.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/NetworkRegistryException.java new file mode 100644 index 00000000..fb22d5f4 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/spi/NetworkRegistryException.java @@ -0,0 +1,19 @@ +package org.slj.mqtt.sn.spi; + +public class NetworkRegistryException extends Exception { + + public NetworkRegistryException() { + } + + public NetworkRegistryException(String message) { + super(message); + } + + public NetworkRegistryException(String message, Throwable cause) { + super(message, cause); + } + + public NetworkRegistryException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/MqttsnUtils.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/MqttsnUtils.java new file mode 100644 index 00000000..181dcec4 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/MqttsnUtils.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.utils; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.model.MqttsnClientState; +import org.slj.mqtt.sn.model.MqttsnWaitToken; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.MqttsnExpectationFailedException; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; + +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class MqttsnUtils { + + private static Logger logger = Logger.getLogger(MqttsnUtils.class.getName()); + + public static double percentOf(double val, double of){ + return val / of * 100; + } + + public static byte[] arrayOf(int size, byte fill){ + byte[] a = new byte[size]; + Arrays.fill(a, fill); + return a; + } + + public static boolean in(MqttsnClientState state, MqttsnClientState... options){ + if(options == null) return false; + for (int i = 0; i < options.length; i++) { + if(options[i] == state) return true; + } + return false; + } + + public static void responseCheck(MqttsnWaitToken token, Optional response) + throws MqttsnExpectationFailedException{ + if(response.isPresent() && + response.get().isErrorMessage()){ + logger.log(Level.WARNING, "error response received from gateway, operation failed; throw to application"); + throw new MqttsnExpectationFailedException("error response received from gateway, operation failed"); + } + if(token.isError()){ + logger.log(Level.WARNING, "token was marked invalid by state machine; throw to application"); + throw new MqttsnExpectationFailedException("token was marked invalid by state machine"); + } + } + + public static int getNextLeaseId(Collection used, int startAt) throws MqttsnException { + if(used.isEmpty()) return startAt; + if(used.size() == ((0xFFFF - startAt) + 1)) throw new MqttsnException("all leases taken"); + TreeSet sortedIds = new TreeSet<>(used); + Integer highest = sortedIds.last(); + if(highest >= 0xFFFF) + throw new MqttsnException("no alias left for use for client"); + + int nextValue = highest.intValue(); + do { + nextValue++; + if(!used.contains(nextValue)) return nextValue; + } while(nextValue <= 0xFFFF); + throw new MqttsnException("unable to assigned lease client"); + } + + public static String getDurationString(long millis) { + + if(millis < 0) { + throw new IllegalArgumentException("must be greater than zero!"); + } + + if(millis < 1000){ + return String.format("%s millisecond%s", millis, millis > 1 ? "s" : ""); + } + + long days = TimeUnit.MILLISECONDS.toDays(millis); + millis -= TimeUnit.DAYS.toMillis(days); + long hours = TimeUnit.MILLISECONDS.toHours(millis); + millis -= TimeUnit.HOURS.toMillis(hours); + long minutes = TimeUnit.MILLISECONDS.toMinutes(millis); + millis -= TimeUnit.MINUTES.toMillis(minutes); + long seconds = TimeUnit.MILLISECONDS.toSeconds(millis); + + StringBuilder sb = new StringBuilder(); + + if(days > 0) { + sb.append(days); + sb.append(String.format(" day%s, ", days > 1 ? "s" : "")); + } + + if(days > 0 || hours > 0) { + sb.append(hours); + sb.append(String.format(" hour%s, ", hours > 1 || hours == 0 ? "s" : "")); + } + + if(hours > 0 || days > 0 || minutes > 0) { + sb.append(minutes); + sb.append(String.format(" minute%s, ", minutes > 1 || minutes == 0 ? "s" : "")); + } + + sb.append(seconds); + sb.append(String.format(" second%s", seconds > 1 ? "s" : "")); + + return(sb.toString()); + } + + public static boolean contains(T[] haystack, T needle){ + if(haystack == null || haystack.length == 0) return false; + for (int i = 0; i < haystack.length; i++) { + if(Objects.equals(haystack[i], needle)){ + return true; + } + } + return false; + } + + public static boolean validTopicScheme(int topicIdType, byte[] topicBytes, boolean topicDataAsString) { + if(topicIdType == MqttsnConstants.TOPIC_PREDEFINED){ + return topicBytes.length == 2; + } else if(topicIdType == MqttsnConstants.TOPIC_NORMAL){ + return topicDataAsString ? validTopicName(new String(topicBytes, MqttsnConstants.CHARSET)) : topicBytes.length == 2; + } else if(topicIdType == MqttsnConstants.TOPIC_SHORT){ + return topicBytes.length == 2; + } + else + return false; + } + + public static boolean validTopicName(String topicString) { + return topicString != null && topicString.trim().length() > 0; + } + + public static boolean validQos(int value) { + if(value < -1 || value > 2) + return false; + return true; + } + + public static boolean validUInt16(int value) { + if(value < 0 || value > MqttsnConstants.USIGNED_MAX_16) + return false; + return true; + } + + public static boolean validInt8(int value) throws MqttsnExpectationFailedException{ + if(value < 0 || value > MqttsnConstants.USIGNED_MAX_8) + return false; + return true; + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/StringTable.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/StringTable.java new file mode 100644 index 00000000..1dbff8bc --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/StringTable.java @@ -0,0 +1,263 @@ +package org.slj.mqtt.sn.utils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + *

StringTable

+ *

+ * StringTable allows developers to quickly visualise tabulated data in user interfaces or logs + * by providing simple API to construct and output the data. + * + * Please see {@link org.codesaurus.utils.StringTableWriters} for available output formats + *

+ * + *
+ *      StringTable st = new StringTable("First Column", "Second Column", "Third Column");
+ *      for (int i = 0; i < 10; i++) {
+ *          st.addRow(i, i, i);
+ *      }
+ *      System.out.println(StringTableWriters.writeStringTableAsCSV(st));
+ *      System.out.println(StringTableWriters.writeStringTableAsASCII(st));
+ *      System.out.println(StringTableWriters.writeStringTableAsHtml(st, false, 2));
+ * 
+ * + * @author Simon Johnson + * @since 01/2004 - initial implementation + * + * 2017 - rollup and row index support + * 2019 - table sort added + * matt h - fixed double numerical sort + */ +public class StringTable { + + private boolean rowIndexAdded = false; + private String tableName; + private List headings; + private List footer; + private List rows = new ArrayList(); + private Map> COLUMN_SORTERS = new HashMap<>(); + + private static final Comparator DEFAULT_SORT = new Comparator() { + public int compare(String o1, String o2) { + String val1 = o1.trim(); + String val2 = o2.trim(); + try { + double d1 = Double.parseDouble(val1); + double d2 = Double.parseDouble(val2); + return Double.compare(d1, d2); + } catch (Exception e) { + } + return String.CASE_INSENSITIVE_ORDER.compare(o1, o2); + } + }; + + public StringTable() { + } + + /** + * Construct a StringTable with defined table headings + * @param headings + */ + public StringTable(String...headings){ + this(null, headings); + } + + public StringTable(String tableName, String[] headings){ + + this.tableName = tableName; + this.headings = new ArrayList(headings.length); + this.headings.addAll(Arrays.asList(headings)); + } + + public List getHeadings() { + return headings; + } + + public void setHeadings(List headings) { + this.headings = headings; + } + + public void setTableName(String tableName){ + this.tableName = tableName; + } + + public String getTableName(){ + return this.tableName; + } + + public void setRows(List rows){ + this.rows = rows; + } + + public List getRows(){ + return rows; + } + + public List getFooter(){ + return footer; + } + + /** + * Show a row index column being output during rendering phase + * @default hidden + */ + public void showRowIndex(){ + rowIndexAdded = true; + } + + /** + * Hide the row index column + */ + public void hideRowIndex(){ + rowIndexAdded = false; + } + + /** + * Apply natural sort on the column index specified. + * Where the data contains only numeric data it will use natural numeric sort + * @param colIdx - Zero indexed value to determine which column to sort by + * @param reverse - Should the sort direct be natural order reversed + */ + public synchronized void sort(final int colIdx, boolean reverse) { + Comparator wrapper = new Comparator() { + public int compare(String[] o1, String[] o2) { + if(o1.length >= (colIdx + 1) && o2.length >= (colIdx + 1)){ + return getComparatorForColumn(colIdx).compare(o1[colIdx].trim(), o2[colIdx].trim()); + } + return 0; + } + }; + rows.sort(reverse ? wrapper.reversed() : wrapper); + } + + /** + * Set the comparator for a given column index allowing full control + * of data sorting + * @param colIdx - Zero indexed value to determine which column to assign comparator to + * @param c - your custom comparator + */ + public void addColumnComparator(int colIdx, Comparator c){ + COLUMN_SORTERS.put(colIdx, c); + } + + private Comparator getComparatorForColumn(int colIdx){ + Comparator c = COLUMN_SORTERS.get(colIdx); + if(c == null) { + c = DEFAULT_SORT; + } + return c; + } + + /** + * Add data to the rollup (footer) of the table + */ + public synchronized void addFooter(String... row){ + if(row == null || row.length == 0) return; + this.footer = new ArrayList(row.length); + this.footer.addAll(Arrays.asList(row)); + } + + public synchronized void addRow(Object... row){ + + if(row == null || row.length == 0) return; + String[] a = new String[row.length]; + for (int i = 0; i < row.length; i++) { + a[i] = String.valueOf(row[i]); + } + rows.add(a); + } + + public void addRows(List rows){ + + Iterator itr = rows.iterator(); + while(itr.hasNext()){ + String[] row = itr.next(); + addRow((Object[])row); + } + } + + /** + * Merge data from other tables into this table + */ + public void combine(StringTable... tables){ + if(tables != null){ + for (StringTable stringTable : tables) { + if(StringTable.getColumnCount(stringTable) == + StringTable.getColumnCount(this)){ + addRows(stringTable.getRows()); + } + } + } + } + + public synchronized void addCellValue(Object s){ + String[] arr = rows.size() == 0 ? null : rows.get(rows.size() - 1); + if(arr == null || arr.length >= getHeadings().size()){ + arr = nextRow(); + } + String[] newarr = new String[arr.length+1]; + System.arraycopy(arr, 0, newarr, 0, arr.length); + newarr[arr.length] = s == null ? "" : String.valueOf(s); + rows.set(rows.size() - 1, newarr); + } + + private String[] nextRow(){ + String[] arr = new String[0]; + rows.add(arr); + return arr; + } + + /** + * Add a row separator into your table data + */ + public void addSeparator(){ + rows.add(null); + } + + public static String getColumnName(StringTable table, int idx){ + if(table.rowIndexAdded) { + if(idx == 0) return "Row"; + idx = idx - 1; + } + return table.getHeadings().get(idx); + } + + /** + * returns the number of columns in the given table + */ + public static int getColumnCount(StringTable table){ + int count = table.getHeadings().size(); + if(table.rowIndexAdded) count++; + return count; + } + + /** + * returns the number of rows in the given table + */ + public static int size(StringTable table){ + return table.getRows().size(); + } + + public String getCellValue(int rowIdx, int colIdx){ + if(rowIndexAdded) { + if(colIdx == 0) return "" + (rowIdx + 1); + colIdx = colIdx - 1; + } + String val = rows.get(rowIdx)[colIdx]; + return val == null ? "" : val; + } + + public void addRows(Collection collection, Function mapper) { + if (null != collection && null != mapper) { + collection.stream().forEach(r -> addRow(mapper.apply(r))); + } + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/StringTableWriters.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/StringTableWriters.java new file mode 100644 index 00000000..5b0eaea2 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/StringTableWriters.java @@ -0,0 +1,272 @@ +package org.slj.mqtt.sn.utils; + +import java.util.Iterator; +import java.util.List; + +/** + * Utilities to convert tabulated model into various output formats + * + * @since 01/2004 + * @author Simon Johnson + * + * Contributors; + * 2019 Matt h - improved ASCII output + */ +public class StringTableWriters { + + static final char PLUS = '+'; + static final char HYPHEN = '-'; + static final char UNDERSCORE = '_'; + static final char PIPE = '|'; + static final char WHITESPACE = ' '; + static final String NEWLINE = System.lineSeparator(); + + public static String writeStringTableAsCSV(StringTable st){ + CSVTextWriter textTable = new CSVTextWriter(); + return textTable.write(st); + } + + public static String writeStringTableAsASCII(StringTable st){ + ASCIITableWriter writer = new ASCIITableWriter(); + return writer.write(st); + } + + public static String writeStringTableAsHTML(StringTable st, boolean borders, int padding){ + HTMLTextWriter htmlTable = new HTMLTextWriter(borders, padding); + return htmlTable.write(st); + } + + private static class HTMLTextWriter { + + boolean borders = false; + int padding; + + public HTMLTextWriter(boolean borders, int padding) { + this.borders = borders; + this.padding = padding; + } + + public String write(StringTable table) { + + StringBuilder sb = new StringBuilder(); + + sb.append(""); + sb.append(""); + sb.append(""); + + //-- headings + List headings = table.getHeadings(); + Iterator itr = headings.iterator(); + while(itr.hasNext()){ + String heading = itr.next(); + sb.append(""); + } + + sb.append(""); + sb.append(""); + + //-- data + List data = table.getRows(); + if(data != null && !data.isEmpty()) { + + Iterator dataItr = data.iterator(); + while(dataItr.hasNext()){ + sb.append(""); + String[] row = dataItr.next(); + for (int i = 0; i < row.length; i++) { + sb.append(""); + } + + sb.append(""); + } + } + + //-- rollup + List footers = table.getFooter(); + if(footers != null && !footers.isEmpty()) { + Iterator footerItr = footers.iterator(); + sb.append(""); + while(footerItr.hasNext()){ + sb.append(""); + } + sb.append(""); + } + + sb.append("
"); + sb.append(heading.trim()); + sb.append("
"); + sb.append(row[i].trim()); + sb.append("
"); + String footer = footerItr.next(); + sb.append(footer.trim()); + sb.append("
"); + return sb.toString(); + } + } + + private static class CSVTextWriter { + + static final char SEP = ','; + static final String NEWLINE = System.lineSeparator(); + + public String write(StringTable table) { + return write(table, SEP); + } + + public String write(StringTable table, char sep) { + + StringBuilder sb = new StringBuilder(); + + //-- headings + List headings = table.getHeadings(); + Iterator itr = headings.iterator(); + while(itr.hasNext()){ + String heading = itr.next(); + sb.append(heading.trim()); + if(itr.hasNext()) sb.append(sep); + } + + //-- data + List data = table.getRows(); + if(data != null && !data.isEmpty()) { + sb.append(NEWLINE); + + Iterator dataItr = data.iterator(); + while(dataItr.hasNext()){ + String[] row = dataItr.next(); + for (int i = 0; i < row.length; i++) { + if(i > 0) sb.append(sep); + sb.append(row[i].trim()); + } + + if(dataItr.hasNext()) sb.append(NEWLINE); + } + } + + //-- rollup + List footers = table.getFooter(); + if(footers != null && !footers.isEmpty()) { + sb.append(NEWLINE); + + Iterator footerItr = footers.iterator(); + while(footerItr.hasNext()){ + String footer = footerItr.next(); + sb.append(footer.trim()); + if(footerItr.hasNext()) sb.append(sep); + } + } + + return sb.toString(); + } + } + + private static class ASCIITableWriter { + + private StringTable table; + private int rowCount; + private int columnCount; + private int[] columnWidths; + private StringBuilder text; + + private String write(StringTable table) { + // Initialise + this.table = table; + this.rowCount = table.getRows().size(); + this.columnCount = StringTable.getColumnCount(table); + + // Calculate column positions. + columnWidths = getColumnWidths(); + + // Write table. + return writeTable(); + } + + private int[] getColumnWidths() { + int[] columnWidths = new int[columnCount]; + for (int i = 0; i < columnCount; i++) { + columnWidths[i] = getColumnWidth(i); + } + return columnWidths; + } + + private int getColumnWidth(int columnIndex) { + int columnWidth = StringTable.getColumnName(table, columnIndex).length(); + for (int i = 0; i < rowCount; i++) { + int valueWidth = table.getCellValue(i, columnIndex).length(); + columnWidth = Math.max(columnWidth, valueWidth); + } + if (null != table.getFooter() && table.getFooter().size() == columnCount) { + String footer = table.getFooter().get(columnIndex); + if (null != footer && footer.length() > columnWidth) { + columnWidth = footer.length(); + } + } + return columnWidth; + } + + private String writeTable() { + text = new StringBuilder(); + + if (null != table.getTableName()) { + text.append(table.getTableName().toUpperCase()); + writeLine(); + } + + writeSeparatorRow(); + writeColumnNames(); + writeSeparatorRow(); + for (int i = 0; i < rowCount; i++) { + writeRow(i); + } + writeSeparatorRow(); + + if (null != table.getFooter() && table.getFooter().size() == columnCount) { + for (int i = 0; i < columnCount; i++) { + writeValue(table.getFooter().get(i), i); + } + writeLine(); + writeSeparatorRow(); + } + + return text.toString(); + } + + private void writeSeparatorRow() { + StringBuilder sb = new StringBuilder(); + for (int width : columnWidths) { + for (int i = 0; i < width + 4; i++) { + sb.append((i == 0 || i == width + 3) ? PLUS : HYPHEN); + } + } + text.append(sb); + writeLine(); + } + + private void writeColumnNames() { + for (int i = 0; i < columnCount; i++) { + writeValue(StringTable.getColumnName(table, i), i); + } + writeLine(); + } + + private void writeRow(int rowIndex) { + for (int i = 0; i < columnCount; i++) { + writeValue(table.getCellValue(rowIndex, i), i); + } + writeLine(); + } + + private void writeValue(String value, int columnIndex) { + StringBuilder sb = new StringBuilder("| ").append(value); + while (sb.length() < columnWidths[columnIndex] + 2) { + sb.append(WHITESPACE); + } + sb.append(" |"); + text.append(sb); + } + + private void writeLine() { + text.append(NEWLINE); + } + } +} diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/TopicPath.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/TopicPath.java new file mode 100644 index 00000000..505a47bb --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/TopicPath.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.utils; + +import java.io.Serializable; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class TopicPath { + +// static final String TOPIC_REGX = "^((^\\/?|\\/)([A-Za-z0-9\\.\\-\\$\\:_]+|$)?)*$"; + static final String SUBCRIPTION_REGX = "^((^\\/?|\\/)([A-Za-z0-9\\.\\-\\$\\:_]+|\\+|#$)?)*$"; + static final String WILDCARD = "#"; + static final String WILDSEG = "+"; + static final String PATHSEP = "/"; + + private Topic topic; + + public TopicPath(String topicPath){ + this.topic = new Topic(topicPath); + } + + public boolean matches(String topicPath) throws ParseException { + Topic matchTopic = new Topic(topicPath); + List msgTokens = topic.getTokens(); + List subscriptionTokens = matchTopic.getTokens(); + int i = 0; + for (; i < subscriptionTokens.size(); i++) { + Token subToken = subscriptionTokens.get(i); + if (subToken != Token.MULTI && subToken != Token.SINGLE) { + if (i >= msgTokens.size()) { + return false; + } + Token msgToken = msgTokens.get(i); + if (!msgToken.equals(subToken)) { + return false; + } + } else { + if (subToken == Token.MULTI) { + return true; + } + if (subToken == Token.SINGLE) { + } + } + } + return i == msgTokens.size(); + } + + public static boolean isWild(String topicPath){ + return topicPath != null && topicPath.contains(WILDCARD) ||topicPath.contains(WILDSEG) ; + } + + public static boolean isValidTopic(String topicPath, int maxLength){ + return topicPath != null && topicPath.trim().length() > 0 && topicPath.trim().length() < maxLength; +// TOPIC_REGX.matches(topicPath); + } + + public static boolean isValidSubscription(String topicPath, int maxLength){ + return topicPath != null && topicPath.trim().length() > 0 && topicPath.trim().length() < maxLength; +// && SUBCRIPTION_REGX.matches(topicPath); + } + + public String toString(){ + return topic.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TopicPath topicPath = (TopicPath) o; + return Objects.equals(topic, topicPath.topic); + } + + @Override + public int hashCode() { + return Objects.hash(topic); + } + + private static class Token { + + static final Token EMPTY = new Token(""); + static final Token MULTI = new Token(WILDCARD); + static final Token SINGLE = new Token(WILDSEG); + final String name; + + protected Token(String s) { + name = s; + } + + @Override + public int hashCode() { + int hash = 7; + hash = 29 * hash + (this.name != null ? this.name.hashCode() : 0); + return hash; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final Token other = (Token) obj; + if ((this.name == null) ? (other.name != null) : !this.name.equals(other.name)) { + return false; + } + return true; + } + + @Override + public String toString() { + return name; + } + } + + private static class Topic implements Serializable { + + private final String topicPath; + private transient List tokens; + + public Topic(String topic) { + this.topicPath = topic; + } + + public List getTokens() throws ParseException { + if (tokens == null) { + tokens = parseTopic(topicPath); + } + return tokens; + } + + private List parseTopic(String topic) throws ParseException { + List res = new ArrayList<>(); + String[] arr = topic.split(PATHSEP); + if (arr.length == 0) { + res.add(Token.EMPTY); + } + if (topic.endsWith(PATHSEP)) { + String[] newArr = new String[arr.length + 1]; + System.arraycopy(arr, 0, newArr, 0, arr.length); + newArr[arr.length] = ""; + arr = newArr; + } + for (int i = 0; i < arr.length; i++) { + String s = arr[i]; + if (s.isEmpty()) { + res.add(Token.EMPTY); + } else if (s.equals(WILDCARD)) { + if (i != arr.length - 1) { + throw new ParseException("bad topic format - the multi symbol (#) has to be the last one after a separator", i); + } + res.add(Token.MULTI); + } else if (s.contains(WILDCARD)) { + throw new ParseException("bad topic format - invalid subtopic name: " + s, i); + } else if (s.equals(WILDSEG)) { + res.add(Token.SINGLE); + } else if (s.contains(WILDSEG)) { + throw new ParseException("bad topic format - invalid subtopic name: " + s, i); + } else { + res.add(new Token(s)); + } + } + return res; + } + + @Override + public String toString() { + return topicPath; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Topic other = (Topic) obj; + return Objects.equals(this.topicPath, other.topicPath); + } + + @Override + public int hashCode() { + return topicPath.hashCode(); + } + } + + public static final void main(String[] args){ + } +} \ No newline at end of file diff --git a/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/TransientObjectLocks.java b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/TransientObjectLocks.java new file mode 100644 index 00000000..f7222650 --- /dev/null +++ b/mqtt-sn-core/src/main/java/org/slj/mqtt/sn/utils/TransientObjectLocks.java @@ -0,0 +1,116 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.utils; + +import java.lang.ref.WeakReference; +import java.util.Map; +import java.util.WeakHashMap; + +public class TransientObjectLocks { + + private final Map> lockMap + = new WeakHashMap<>(); + + public IMutex mutex(String id) { + + if (id == null) { + throw new NullPointerException(); + } + + IMutex key = new IdMutex(id); + + synchronized (lockMap) { + + WeakReference ref = lockMap.get(key); + if (ref == null) { + + lockMap.put(key, + new WeakReference(key)); + return key; + } + + IMutex mutex = (IMutex) ref.get(); + if (mutex == null) { + + lockMap.put(key, + new WeakReference(key)); + return key; + } + return mutex; + } + } + + public int getMutexCount() { + + synchronized (lockMap) { + + return lockMap.size(); + } + } + + static interface IMutex { } + + static class IdMutex implements IMutex { + + private final String id; + + protected IdMutex(String id) { + + this.id = id; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((id == null) ? 0 : id.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + IdMutex other = (IdMutex) obj; + if (id == null) { + if (other.id != null) + return false; + } else if (!id.equals(other.id)) + return false; + return true; + } + + public String toString() { + return id; + } + } +} diff --git a/mqtt-sn-gateway-paho-connector/README.md b/mqtt-sn-gateway-paho-connector/README.md new file mode 100644 index 00000000..d261934c --- /dev/null +++ b/mqtt-sn-gateway-paho-connector/README.md @@ -0,0 +1,2 @@ +# MQTT-SN Paho Connector +Provides the backend TCP/IP connection to the MQTT broker. \ No newline at end of file diff --git a/mqtt-sn-gateway-paho-connector/dependency-reduced-pom.xml b/mqtt-sn-gateway-paho-connector/dependency-reduced-pom.xml new file mode 100644 index 00000000..6a5c728b --- /dev/null +++ b/mqtt-sn-gateway-paho-connector/dependency-reduced-pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + org.slj + mqtt-sn-gateway-paho-connector + 1.0.0 + + mqtt-sn-gateway-${version} + + + maven-shade-plugin + 2.3 + + + package + + shade + + + + + org.slj.mqtt.sn.gateway.impl.AggregatingGatewayInteractiveMain + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + + junit + junit + 4.13 + test + + + hamcrest-core + org.hamcrest + + + + + + 1.5.0 + 3.8.1 + 2.4.3 + 1.8 + UTF-8 + 1.8 + 4.13 + + + diff --git a/mqtt-sn-gateway-paho-connector/mqtt-sn-gateway-paho-connector.iml b/mqtt-sn-gateway-paho-connector/mqtt-sn-gateway-paho-connector.iml new file mode 100644 index 00000000..43579f4a --- /dev/null +++ b/mqtt-sn-gateway-paho-connector/mqtt-sn-gateway-paho-connector.iml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mqtt-sn-gateway-paho-connector/pom.xml b/mqtt-sn-gateway-paho-connector/pom.xml new file mode 100644 index 00000000..362b0619 --- /dev/null +++ b/mqtt-sn-gateway-paho-connector/pom.xml @@ -0,0 +1,120 @@ + + + + + 4.0.0 + + org.slj + mqtt-sn-gateway-paho-connector + 1.0.0 + + + UTF-8 + 4.13 + 3.8.1 + 1.8 + 1.8 + 2.4.3 + 1.5.0 + + + + + org.slj + mqtt-sn-core + 1.0.0 + + + org.slj + mqtt-sn-codec + 1.0.0 + + + org.slj + mqtt-sn-gateway + 1.0.0 + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + 1.2.5 + + + junit + junit + ${junit.version} + test + + + + + mqtt-sn-gateway-${version} + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + + + package + + shade + + + + + + + org.slj.mqtt.sn.gateway.impl.AggregatingGatewayInteractiveMain + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + \ No newline at end of file diff --git a/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/AggregatingGatewayInteractiveMain.java b/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/AggregatingGatewayInteractiveMain.java new file mode 100644 index 00000000..20ac9689 --- /dev/null +++ b/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/AggregatingGatewayInteractiveMain.java @@ -0,0 +1,58 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.gateway.impl; + +import org.slj.mqtt.sn.codec.MqttsnCodecs; +import org.slj.mqtt.sn.gateway.cli.MqttsnInteractiveGateway; +import org.slj.mqtt.sn.gateway.cli.MqttsnInteractiveGatewayLauncher; +import org.slj.mqtt.sn.gateway.impl.broker.MqttsnAggregatingBrokerService; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerOptions; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.spi.IMqttsnTransport; + +public class AggregatingGatewayInteractiveMain { + public static void main(String[] args) throws Exception { + MqttsnInteractiveGatewayLauncher.launch(new MqttsnInteractiveGateway() { + protected AbstractMqttsnRuntimeRegistry createRuntimeRegistry(MqttsnOptions options, IMqttsnTransport transport) { + + MqttsnBrokerOptions brokerOptions = new MqttsnBrokerOptions(). + withHost(hostName). + withPort(port). + withUsername(username). + withPassword(password); + + return MqttsnGatewayRuntimeRegistry.defaultConfiguration(options). + withBrokerConnectionFactory(new PahoMqttsnBrokerConnectionFactory()). + withBrokerService(new MqttsnAggregatingBrokerService(brokerOptions)). + withTransport(createTransport()). + withCodec(MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2); + } + }); + } +} diff --git a/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/AggregatingGatewayMain.java b/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/AggregatingGatewayMain.java new file mode 100644 index 00000000..41b3f698 --- /dev/null +++ b/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/AggregatingGatewayMain.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl; + +import org.slj.mqtt.sn.codec.MqttsnCodecs; +import org.slj.mqtt.sn.gateway.impl.broker.MqttsnAggregatingBrokerService; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerOptions; +import org.slj.mqtt.sn.gateway.spi.gateway.MqttsnGatewayOptions; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.net.MqttsnUdpOptions; +import org.slj.mqtt.sn.net.MqttsnUdpTransport; + +public class AggregatingGatewayMain { + public static void main(String[] args) throws Exception { + if(args.length < 6) + throw new IllegalArgumentException("you must specify 6 arguments; , , , , and "); + + //-- the local port on which to listen + int localPort = Integer.valueOf(args[0].trim()); + + //-- the clientId of the MQTT broker you are connecting to + String clientId = args[1].trim(); + + //-- the host of the MQTT broker you are connecting to + String host = args[2].trim(); + + //-- the port of the MQTT broker you are connecting to + int port = Integer.valueOf(args[3].trim()); + + //-- the username of the MQTT broker you are connecting to + String username = args[4].trim(); + + //-- the password of the MQTT broker you are connecting to + String password = args[5].trim(); + + MqttsnBrokerOptions brokerOptions = new MqttsnBrokerOptions(). + withHost(host). + withPort(port). + withUsername(username). + withPassword(password); + + //-- configure your gateway runtime + MqttsnOptions gatewayOptions = new MqttsnGatewayOptions(). + withGatewayId(1). + withMaxConnectedClients(10). + withContextId(clientId). + withPredefinedTopic("/my/example/topic/1", 1); + + //-- construct the registry of controllers and config + AbstractMqttsnRuntimeRegistry registry = MqttsnGatewayRuntimeRegistry.defaultConfiguration(gatewayOptions). + withBrokerConnectionFactory(new PahoMqttsnBrokerConnectionFactory()). + withBrokerService(new MqttsnAggregatingBrokerService(brokerOptions)). + withTransport(new MqttsnUdpTransport(new MqttsnUdpOptions().withPort(localPort))). + withCodec(MqttsnCodecs.MQTTSN_CODEC_VERSION_1_2); + + MqttsnGateway gateway = new MqttsnGateway(); + + //-- start the gateway and specify if you wish to join the main gateway thread (blocking) or + //-- specify false to run async if you are embedding + gateway.start(registry, true); + } +} diff --git a/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/PahoMqttsnBrokerConnection.java b/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/PahoMqttsnBrokerConnection.java new file mode 100644 index 00000000..fcd07f18 --- /dev/null +++ b/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/PahoMqttsnBrokerConnection.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl; + +import org.eclipse.paho.client.mqttv3.*; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.slj.mqtt.sn.gateway.impl.broker.AbstractMqttsnBrokerConnection; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerException; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerOptions; +import org.slj.mqtt.sn.model.IMqttsnContext; + +import java.util.Arrays; +import java.util.LinkedList; +import java.util.Queue; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * @author simonjohnson + * + * Really simple backend connection to an MQTT broker using the PAHO client library. A single connection is managed by the runtime + * and will be connected either eagerly on startup or lazily according to configuration + */ +public class PahoMqttsnBrokerConnection extends AbstractMqttsnBrokerConnection implements MqttCallback { + + private Logger logger = Logger.getLogger(PahoMqttsnBrokerConnection.class.getName()); + private volatile MqttClient client = null; + private MqttsnBrokerOptions options = null; + private final String clientId; + private Thread publishingThread = null; + private final Object monitor = new Object(); + private final Queue queue = new LinkedList<>(); + private volatile boolean running = false; + + public PahoMqttsnBrokerConnection(MqttsnBrokerOptions options, String clientId) { + this.options = options; + this.clientId = clientId; + } + + public void connect() throws MqttException { + if(client == null || !client.isConnected()){ + synchronized (this){ + if(client == null || !client.isConnected()){ + initClient(); + MqttConnectOptions connectOptions = new MqttConnectOptions(); + connectOptions.setAutomaticReconnect(false); + connectOptions.setPassword(options.getPassword().toCharArray()); + connectOptions.setUserName(options.getUsername()); + connectOptions.setKeepAliveInterval(options.getKeepAlive()); + connectOptions.setConnectionTimeout(options.getConnectionTimeout()); + client.connect(connectOptions); + logger.log(Level.INFO, String.format("connecting client with username [%s] and keepAlive [%s]", options.getUsername(), options.getKeepAlive())); + } + } + } + } + + private void initClient() throws MqttException { + String connectionStr = String.format("%s://%s:%s", options.getProtocol(), options.getHost(), options.getPort()); + client = new MqttClient(connectionStr, clientId, new MemoryPersistence()); + client.setCallback(this); + client.setTimeToWait(options.getConnectionTimeout() * 1000); + logger.log(Level.INFO, String.format("initiated client with host [%s] and clientId [%s]", connectionStr, clientId)); + initPublisher(); + } + + private void initPublisher(){ + running = true; + publishingThread = new Thread(() -> { + do { + try { + if(client != null && client.isConnected()) { + PublishOp op = queue.poll(); + if(op != null){ + logger.log(Level.INFO, String.format("dequeing message to PAHO from queue, [%s] remaining", queue.size())); + client.publish(op.topicPath, op.data, op.QoS, op.retain); + } + } + if(queue.peek() == null) { + synchronized (monitor){ + monitor.wait(); + } + } + } catch(Exception e){ + logger.log(Level.SEVERE, String.format("error publishing via PAHO queue publisher"), e); + } + } while(running); + }, "mqtt-sn-broker-paho-publisher"); + publishingThread.setDaemon(true); + publishingThread.setPriority(Thread.MIN_PRIORITY); + publishingThread.start(); + } + + @Override + public boolean isConnected() throws MqttsnBrokerException { + return client != null && client.isConnected(); + } + + @Override + public void close() { + try { + logger.log(Level.INFO, "closing connection to broker"); + client.disconnect(); + client.close(true); + client = null; + } catch(MqttException e){ + logger.log(Level.SEVERE, "error encountered closing paho client;", e); + } finally { + running = false; + } + } + + @Override + public boolean disconnect(IMqttsnContext context, int keepAlive) throws MqttsnBrokerException { + return true; + } + + public boolean connect(IMqttsnContext context, boolean cleanSession, int keepAlive) throws MqttsnBrokerException{ + return true; + } + + @Override + public boolean subscribe(IMqttsnContext context, String topicPath, int QoS) throws MqttsnBrokerException { + try { + logger.log(Level.INFO, String.format("subscribing connection to [%s] -> [%s]", topicPath, QoS)); + client.subscribe(topicPath, QoS); + return true; + } catch(MqttException e){ + throw new MqttsnBrokerException(e); + } + } + + @Override + public boolean unsubscribe(IMqttsnContext context, String topicPath) throws MqttsnBrokerException { + try { + logger.log(Level.INFO, String.format("unsubscribing connection from [%s]", topicPath)); + client.unsubscribe(topicPath); + return true; + } catch(MqttException e){ + throw new MqttsnBrokerException(e); + } + } + + @Override + public boolean publish(IMqttsnContext context, String topicPath, int QoS, boolean retain, byte[] data) throws MqttsnBrokerException { + try { + PublishOp op = new PublishOp(); + op.data = data; + op.context = context; + op.retain = retain; + op.topicPath = topicPath; + op.QoS = QoS; + queue.add(op); + logger.log(Level.INFO, String.format("queuing message for publish [%s] -> [%s] bytes, queue contains [%s]", topicPath, data.length, queue.size())); + synchronized (monitor){ + monitor.notify(); + } + return true; + } catch(Exception e){ + throw new MqttsnBrokerException(e); + } + } + + @Override + public void connectionLost(Throwable t) { + logger.log(Level.SEVERE, "connection reported lost on broker side", t); + try { + client.close(true); + } catch(Exception e){ + } finally { + client = null; + running = false; + } + } + + @Override + public void messageArrived(String s, MqttMessage mqttMessage) throws Exception { + try { + byte[] data = mqttMessage.getPayload(); + logger.log(Level.INFO, String.format("recieved message from connection [%s] -> [%s] bytes", s, data.length)); + receive(s, data, mqttMessage.getQos()); + } catch(Exception e){ + logger.log(Level.SEVERE, "gateway reported issue receiving message from broker;", e); + } + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + if(logger.isLoggable(Level.FINE)){ + logger.log(Level.FINE, String.format("broker confirm delivery complete [%s] -> [%s]", + token.getMessageId(), Arrays.toString(token.getTopics()))); + } + } + + static class PublishOp { + public IMqttsnContext context; + public String topicPath; + public int QoS; + public boolean retain; + public byte[] data; + } +} \ No newline at end of file diff --git a/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/PahoMqttsnBrokerConnectionFactory.java b/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/PahoMqttsnBrokerConnectionFactory.java new file mode 100644 index 00000000..215a7e4c --- /dev/null +++ b/mqtt-sn-gateway-paho-connector/src/main/java/org/slj/mqtt/sn/gateway/impl/PahoMqttsnBrokerConnectionFactory.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl; + +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerConnectionFactory; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerException; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerOptions; + +public class PahoMqttsnBrokerConnectionFactory implements IMqttsnBrokerConnectionFactory { + + @Override + public PahoMqttsnBrokerConnection createConnection(MqttsnBrokerOptions options, String clientId) throws MqttsnBrokerException { + try { + PahoMqttsnBrokerConnection connection = new PahoMqttsnBrokerConnection(options, clientId); + connection.connect(); + return connection; + } catch(Exception e){ + throw new MqttsnBrokerException("error creating connection;", e); + } + } +} diff --git a/mqtt-sn-gateway/pom.xml b/mqtt-sn-gateway/pom.xml new file mode 100644 index 00000000..d6f13ea1 --- /dev/null +++ b/mqtt-sn-gateway/pom.xml @@ -0,0 +1,71 @@ + + + + + 4.0.0 + + org.slj + mqtt-sn-gateway + 1.0.0 + + + UTF-8 + 4.13 + 3.8.1 + 1.8 + 1.8 + + + + + org.slj + mqtt-sn-codec + 1.0.0 + + + org.slj + mqtt-sn-core + 1.0.0 + + + junit + junit + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + \ No newline at end of file diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/cli/MqttsnInteractiveGateway.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/cli/MqttsnInteractiveGateway.java new file mode 100644 index 00000000..43fe1f2c --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/cli/MqttsnInteractiveGateway.java @@ -0,0 +1,351 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.gateway.cli; + +import org.slj.mqtt.sn.cli.AbstractInteractiveCli; +import org.slj.mqtt.sn.gateway.impl.MqttsnGateway; +import org.slj.mqtt.sn.gateway.impl.MqttsnGatewayRuntimeRegistry; +import org.slj.mqtt.sn.gateway.spi.gateway.MqttsnGatewayOptions; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntime; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.impl.AbstractMqttsnUdpTransport; +import org.slj.mqtt.sn.model.*; +import org.slj.mqtt.sn.net.MqttsnUdpOptions; +import org.slj.mqtt.sn.net.MqttsnUdpTransport; +import org.slj.mqtt.sn.spi.IMqttsnTransport; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.NetworkRegistryException; + +import java.io.IOException; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.*; + +public abstract class MqttsnInteractiveGateway extends AbstractInteractiveCli { + + static final String USERNAME = "username"; + static final String PASSWORD = "password"; + + protected String username; + protected String password; + + enum COMMANDS { + NETWORK("View network registry", new String[0]), + STATS("View statistics for runtime", new String[0]), + RESET("Reset the stats", new String[0]), + QUEUE("Queue a new message for clients", new String[]{"String* topicName, String* payload, int QoS"}), + SESSION("Obtain the status of a client", new String[]{"String* clientId"}), + STATUS("Obtain the status of the runtime", new String[0]), + PREDEFINE("Add a predefined topic alias", new String[]{"String* topicName", "int16 topicAlias"}), + HELP("List this message", new String[0]), + QUIT("Quit the application", new String[0]), + EXIT("Quit the application", new String[0], true), + BYE("Quit the application", new String[0], true); + + private String description; + private String[] arguments; + private boolean hidden = false; + + COMMANDS(String description, String[] arguments, boolean hidden){ + this(description, arguments); + this.hidden = hidden; + } + + COMMANDS(String description, String[] arguments){ + this.description = description; + this.arguments = arguments; + } + + public boolean isHidden() { + return hidden; + } + + public String[] getArguments() { + return arguments; + } + + public String getDescription(){ + return description; + } + } + + @Override + protected boolean processCommand(String command) throws Exception { + COMMANDS c = COMMANDS.valueOf(command.toUpperCase()); + processCommand(c); + if(c == COMMANDS.QUIT || c == COMMANDS.BYE || c == COMMANDS.EXIT){ + return false; + } + return true; + } + + protected void processCommand(COMMANDS command) throws Exception { + try { + switch (command){ + case HELP: + for(COMMANDS c : COMMANDS.values()){ + if(c.isHidden()) continue; + StringBuilder sb = new StringBuilder(); + for(String a : c.getArguments()){ + if(sb.length() > 0){ + sb.append(", "); + } + sb.append(a); + } + output.println("\t" + c.name()); + output.println("\t\t" + c.getDescription()); + } + break; + case SESSION: + session(captureMandatoryString(input, output, "Please supply the clientId whose session you would like to see")); + break; + case NETWORK: + network(); + break; + case STATS: + stats(); + break; + case RESET: + resetMetrics(); + break; + case STATUS: + status(); + break; + case PREDEFINE: + predefine( + captureMandatoryString(input, output, "What is the topic you would like to predefine?"), + captureMandatoryInt(input, output, "What is the alias for the topic?", null)); + break; + case QUEUE: + queue( + captureMandatoryString(input, output, "What is the topic you would like to publish to?"), + captureMandatoryString(input, output, "Supply the data to publish"), + captureMandatoryInt(input, output, "What is QoS for the publish?", new int[]{0,1,2})); + break; + case EXIT: + case BYE: + case QUIT: + quit(); + break; + } + } catch(Exception e){ + error( "An error occurred running your command.", e); + } + } + + protected void queue(String topicName, String payload, int QoS) + throws IOException, MqttsnException { + + message("Enqueued publish to all subscribed sessions: " + topicName); + MqttsnGatewayRuntimeRegistry gatewayRuntimeRegistry = (MqttsnGatewayRuntimeRegistry) getRuntimeRegistry(); + gatewayRuntimeRegistry.getGatewaySessionService().receiveToSessions(topicName, payload.getBytes(StandardCharsets.UTF_8), QoS); + } + + protected void network() throws IOException, MqttsnException, NetworkRegistryException { + message("Network registry: "); + MqttsnGatewayRuntimeRegistry gatewayRuntimeRegistry = (MqttsnGatewayRuntimeRegistry) getRuntimeRegistry(); + Iterator itr = gatewayRuntimeRegistry.getNetworkRegistry().iterator(); + while(itr.hasNext()){ + INetworkContext c = itr.next(); + message("\t" + c + " -> " + gatewayRuntimeRegistry.getNetworkRegistry().hasBoundSessionContext(c)); + } + } + + protected void session(String clientId) + throws IOException, MqttsnException { + MqttsnGatewayOptions opts = (MqttsnGatewayOptions) getOptions(); + MqttsnGatewayRuntimeRegistry gatewayRuntimeRegistry = (MqttsnGatewayRuntimeRegistry) getRuntimeRegistry(); + Optional context = + gatewayRuntimeRegistry.getGatewaySessionService().lookupClientIdSession(clientId); + if(context.isPresent()){ + IMqttsnContext c = context.get(); + IMqttsnSessionState state = gatewayRuntimeRegistry. + getGatewaySessionService().getSessionState(c, false); + + message("Client session: " + clientId); + message("Session started: " + format(state.getSessionStarted())); + message("Last seen: " + format(state.getLastSeen())); + message("Keep alive (seconds): " + state.getKeepAlive()); + message("Session length (seconds): " + ((System.currentTimeMillis() - state.getSessionStarted().getTime()) / 1000)); + message("State: " + getColorForState(state.getClientState()) + state.getClientState().name()); + message("Queue size: " + gatewayRuntimeRegistry.getMessageQueue().size(c)); + + Set subs = gatewayRuntimeRegistry.getSubscriptionRegistry().readSubscriptions(c); + Iterator itr = subs.iterator(); + message("Subscription(s): "); + synchronized (subs){ + while(itr.hasNext()){ + Subscription s = itr.next(); + message("\t" + s.getTopicPath() + " -> " + s.getQoS()); + } + } + + INetworkContext networkContext = gatewayRuntimeRegistry.getNetworkRegistry().getContext(c); + message("Network Address(s): " + networkContext.getNetworkAddress()); + + } else { + message("No session found: " + clientId); + } + } + + protected void status() + throws IOException, MqttsnException { + MqttsnGatewayOptions opts = (MqttsnGatewayOptions) getOptions(); + MqttsnGatewayRuntimeRegistry gatewayRuntimeRegistry = (MqttsnGatewayRuntimeRegistry) getRuntimeRegistry(); + if(runtime != null) { + + boolean connected = gatewayRuntimeRegistry.getBrokerService().isConnected(null); + + int maxClients = opts.getMaxConnectedClients(); + int advertiseTime = opts.getGatewayAdvertiseTime(); + + //-- general stuff + message("Gateway Id: " + opts.getGatewayId()); + message("Advertise Interval: " + advertiseTime); + + if(gatewayRuntimeRegistry.getTransport() instanceof AbstractMqttsnUdpTransport){ + MqttsnUdpOptions udpOptions = ((AbstractMqttsnUdpTransport)gatewayRuntimeRegistry.getTransport()).getUdpOptions(); + message("Host: " + udpOptions.getHost()); + message("Datagram port: " + udpOptions.getPort()); + message("Secure port: " + udpOptions.getSecurePort()); + message("Broadcast port: " + udpOptions.getBroadcastPort()); + message("MTU: " + udpOptions.getMtu()); + } + + message("Max message size: " + getOptions().getMaxProtocolMessageSize()); + message("Max connected clients: " + maxClients); + + if (getOptions() != null) { + Map pTopics = getOptions().getPredefinedTopics(); + if(pTopics != null){ + message( "Predefined topic count: " + pTopics.size()); + Iterator itr = pTopics.keySet().iterator(); + while(itr.hasNext()){ + String topic = itr.next(); + message( "\t" + topic + " = " + pTopics.get(topic)); + } + } + } + + Iterator sessionItr = gatewayRuntimeRegistry.getGatewaySessionService().iterator(); + List allState = new ArrayList<>(); + + int queuedMessages = 0; + while(sessionItr.hasNext()){ + IMqttsnContext c = sessionItr.next(); + IMqttsnSessionState state = gatewayRuntimeRegistry.getGatewaySessionService(). + getSessionState(c, false); + allState.add(state); + queuedMessages += gatewayRuntimeRegistry.getMessageQueue().size(c); + } + + message("Currently connected clients: " + allState.size()); + message("Current buffered messages: " + queuedMessages); + + //-- broker stuff +// message("Broker hostname: " + hostName); +// message("Broker port: " + port); + message("Broker TCP/IP Connection State: " + (connected ? cli_green() + "ESTABLISHED" : cli_red() + "UNESTABLISHED")); + + } else { + message( "Gateway status: awaiting connection.."); + } + } + + @Override + protected MqttsnOptions createOptions() throws UnknownHostException { + return new MqttsnGatewayOptions(). + withGatewayId(101). + withContextId(clientId). + withMaxMessagesInQueue(100). + withMinFlushTime(400). + withSleepClearsRegistrations(false); + } + + @Override + protected IMqttsnTransport createTransport() { + MqttsnUdpOptions udpOptions = new MqttsnUdpOptions(). + withPort(MqttsnUdpOptions.DEFAULT_LOCAL_PORT); + return new MqttsnUdpTransport(udpOptions); + } + + @Override + protected AbstractMqttsnRuntime createRuntime(AbstractMqttsnRuntimeRegistry registry, MqttsnOptions options) { + MqttsnGateway gateway = new MqttsnGateway(); + return gateway; + } + + @Override + public void start() throws Exception { + super.start(); + getRuntime().start(getRuntimeRegistry(), false); + } + + @Override + protected void configure() throws IOException { + super.configure(); + username = captureMandatoryString(input, output, "Please enter a valid username for you broker connection"); + password = captureMandatoryString(input, output, "Please enter a valid password for you broker connection"); + } + + @Override + protected void loadConfigHistory(Properties props) throws IOException { + super.loadConfigHistory(props); + username = props.getProperty(USERNAME); + password = props.getProperty(PASSWORD); + } + + @Override + protected void saveConfigHistory(Properties props) { + super.saveConfigHistory(props); + props.setProperty(USERNAME, username); + props.setProperty(PASSWORD, password); + } + + @Override + protected String getPropertyFileName() { + return "gateway.properties"; + } + + private static String format(Date d){ + SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss"); + return sdf.format(d); + } + + public String getColorForState(MqttsnClientState state){ + if(state == null) return cli_reset(); + switch(state){ + case AWAKE: + case CONNECTED: return cli_green(); + case ASLEEP: return cli_blue(); + case PENDING: return cli_reset(); + default: return cli_red(); + } + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/cli/MqttsnInteractiveGatewayLauncher.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/cli/MqttsnInteractiveGatewayLauncher.java new file mode 100644 index 00000000..6d822b1c --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/cli/MqttsnInteractiveGatewayLauncher.java @@ -0,0 +1,53 @@ +/* + * + * * Copyright (c) 2021 Simon Johnson + * * + * * Find me on GitHub: + * * https://github.com/simon622 + * * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + * + */ + +package org.slj.mqtt.sn.gateway.cli; + +import java.io.PrintStream; +import java.util.Scanner; +import java.util.logging.LogManager; + +public class MqttsnInteractiveGatewayLauncher { + public static void launch(MqttsnInteractiveGateway interactiveGateway) throws Exception { + if(!Boolean.getBoolean("debug")) LogManager.getLogManager().reset(); + Scanner input = new Scanner(System.in); + PrintStream output = System.out; + try { + interactiveGateway.init(input, output); + interactiveGateway.welcome(); + interactiveGateway.configureWithHistory(); + interactiveGateway.start(); + interactiveGateway.command(); + interactiveGateway.exit(); + } catch(Exception e){ + System.err.println("A fatal error was encountered: " + e.getMessage()); + } finally { + input.close(); + interactiveGateway.stop(); + } + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/MqttsnGateway.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/MqttsnGateway.java new file mode 100644 index 00000000..4667d113 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/MqttsnGateway.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl; + +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewayRuntimeRegistry; +import org.slj.mqtt.sn.impl.AbstractMqttsnRuntime; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.IMqttsnPublishReceivedListener; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.io.IOException; +import java.util.logging.Level; + +public class MqttsnGateway extends AbstractMqttsnRuntime { + + protected void startupServices(IMqttsnRuntimeRegistry runtime) throws MqttsnException { + + //-- ensure we start all the startable services + callStartup(runtime.getMessageHandler()); + callStartup(runtime.getMessageQueue()); + callStartup(runtime.getMessageRegistry()); + callStartup(runtime.getTopicRegistry()); + callStartup(runtime.getSubscriptionRegistry()); + callStartup(runtime.getMessageStateService()); + callStartup(runtime.getQueueProcessorStateCheckService()); + callStartup(runtime.getQueueProcessor()); + callStartup(runtime.getContextFactory()); + if (runtime.getPermissionService() != null) callStartup(runtime.getPermissionService()); + + //-- start the network last + callStartup(((IMqttsnGatewayRuntimeRegistry) runtime).getGatewaySessionService()); + callStartup(((IMqttsnGatewayRuntimeRegistry) runtime).getBrokerConnectionFactory()); + callStartup(((IMqttsnGatewayRuntimeRegistry) runtime).getBrokerService()); + + //-- start discovery + if (runtime.getOptions().isEnableDiscovery()) { + callStartup(((IMqttsnGatewayRuntimeRegistry) runtime).getGatewayAdvertiseService()); + } + + callStartup(runtime.getTransport()); + + //-- notify the broker of confirmed messahe + registerReceivedListener(new IMqttsnPublishReceivedListener() { + @Override + public void receive(IMqttsnContext context, String topicName, int QoS, byte[] data) { + try { + ((IMqttsnGatewayRuntimeRegistry) registry).getBrokerService().publish(context, topicName, QoS, data); + } catch (MqttsnException e) { + logger.log(Level.SEVERE, "error publishing message to broker", e); + } + } + }); + } + + public void stopServices(IMqttsnRuntimeRegistry runtime) throws MqttsnException { + + //-- stop the networks first + callShutdown(runtime.getTransport()); + + if (runtime.getOptions().isEnableDiscovery()) { + callShutdown(((IMqttsnGatewayRuntimeRegistry) runtime).getGatewayAdvertiseService()); + } + + callShutdown(((IMqttsnGatewayRuntimeRegistry) runtime).getGatewaySessionService()); + callShutdown(((IMqttsnGatewayRuntimeRegistry) runtime).getBrokerConnectionFactory()); + callShutdown(((IMqttsnGatewayRuntimeRegistry) runtime).getBrokerService()); + + //-- ensure we stop all the startable services + + if (runtime.getPermissionService() != null) callShutdown(runtime.getPermissionService()); + callShutdown(runtime.getContextFactory()); + callShutdown(runtime.getMessageHandler()); + callShutdown(runtime.getMessageQueue()); + callShutdown(runtime.getMessageRegistry()); + callShutdown(runtime.getTopicRegistry()); + callShutdown(runtime.getSubscriptionRegistry()); + callShutdown(runtime.getQueueProcessorStateCheckService()); + callShutdown(runtime.getQueueProcessor()); + callShutdown(runtime.getMessageStateService()); + } + + public boolean handleRemoteDisconnect(IMqttsnContext context) { + return true; + } + + public boolean handleLocalDisconnectError(IMqttsnContext context, Throwable t) { + return true; + } + + @Override + public void close() throws IOException { + try { + stop(); + } catch(Exception e){ + throw new IOException(e); + } + } +} \ No newline at end of file diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/MqttsnGatewayRuntimeRegistry.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/MqttsnGatewayRuntimeRegistry.java new file mode 100644 index 00000000..4b45d571 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/MqttsnGatewayRuntimeRegistry.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl; + +import org.slj.mqtt.sn.gateway.impl.gateway.*; +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerConnectionFactory; +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerService; +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewayAdvertiseService; +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewayRuntimeRegistry; +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewaySessionRegistryService; +import org.slj.mqtt.sn.impl.*; +import org.slj.mqtt.sn.impl.ram.*; +import org.slj.mqtt.sn.model.MqttsnOptions; +import org.slj.mqtt.sn.net.NetworkAddressRegistry; +import org.slj.mqtt.sn.spi.MqttsnRuntimeException; + +public class MqttsnGatewayRuntimeRegistry extends AbstractMqttsnRuntimeRegistry implements IMqttsnGatewayRuntimeRegistry { + + private IMqttsnGatewayAdvertiseService advertiseService; + private IMqttsnBrokerService brokerService; + private IMqttsnBrokerConnectionFactory connectionFactory; + private IMqttsnGatewaySessionRegistryService sessionService; + + public MqttsnGatewayRuntimeRegistry(MqttsnOptions options){ + super(options); + } + + public static MqttsnGatewayRuntimeRegistry defaultConfiguration(MqttsnOptions options){ + MqttsnGatewaySessionService sessionService = new MqttsnGatewaySessionService(); + final MqttsnGatewayRuntimeRegistry registry = (MqttsnGatewayRuntimeRegistry) new MqttsnGatewayRuntimeRegistry(options). + withGatewaySessionService(new MqttsnGatewaySessionService()). + withGatewayAdvertiseService(new MqttsnGatewayAdvertiseService()). + withMessageHandler(new MqttsnGatewayMessageHandler()). + withMessageRegistry(new MqttsnInMemoryMessageRegistry()). + withNetworkAddressRegistry(new NetworkAddressRegistry(options.getMaxNetworkAddressEntries())). + withMessageQueue(new MqttsnInMemoryMessageQueue()). + withContextFactory(new MqttsnContextFactory()). + withTopicRegistry(new MqttsnInMemoryTopicRegistry()). + withQueueProcessor(new MqttsnMessageQueueProcessor(false)). + withQueueProcessorStateCheck(new MqttsnGatewayQueueProcessorStateService()). + withSubscriptionRegistry(new MqttsnInMemorySubscriptionRegistry()). + withPermissionService(new MqttsnGatewayPermissionService()). + withMessageStateService(new MqttsnInMemoryMessageStateService(false)); + return registry; + } + + public MqttsnGatewayRuntimeRegistry withGatewayAdvertiseService(IMqttsnGatewayAdvertiseService advertiseService){ + this.advertiseService = advertiseService; + return this; + } + + public MqttsnGatewayRuntimeRegistry withBrokerConnectionFactory(IMqttsnBrokerConnectionFactory connectionFactory){ + this.connectionFactory = connectionFactory; + return this; + } + + public MqttsnGatewayRuntimeRegistry withGatewaySessionService(IMqttsnGatewaySessionRegistryService sessionService){ + this.sessionService = sessionService; + return this; + } + + public MqttsnGatewayRuntimeRegistry withBrokerService(IMqttsnBrokerService brokerService){ + this.brokerService = brokerService; + return this; + } + + @Override + public IMqttsnGatewaySessionRegistryService getGatewaySessionService() { + return sessionService; + } + + @Override + public IMqttsnBrokerService getBrokerService() { + return brokerService; + } + + @Override + public IMqttsnBrokerConnectionFactory getBrokerConnectionFactory() { + return connectionFactory; + } + + public IMqttsnGatewayAdvertiseService getGatewayAdvertiseService() { + return advertiseService; + } + + @Override + protected void validateOnStartup() throws MqttsnRuntimeException { + if(brokerService == null) throw new MqttsnRuntimeException("message state service must be bound for valid runtime"); + if(connectionFactory == null) throw new MqttsnRuntimeException("connection factory must be bound for valid runtime"); + if(sessionService == null) throw new MqttsnRuntimeException("session service must be bound for valid runtime"); + } +} \ No newline at end of file diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/AbstractMqttsnBrokerConnection.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/AbstractMqttsnBrokerConnection.java new file mode 100644 index 00000000..618b095f --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/AbstractMqttsnBrokerConnection.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl.broker; + +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerConnection; +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerService; +import org.slj.mqtt.sn.spi.MqttsnException; + +/** + * Allow the connection to be bootstrapped so implementers can simply extend this to provide their callbacks + */ +public abstract class AbstractMqttsnBrokerConnection implements IMqttsnBrokerConnection { + + protected IMqttsnBrokerService brokerService; + + public void setBrokerService(IMqttsnBrokerService brokerService){ + this.brokerService = brokerService; + } + + public void receive(String topicPath, byte[] payload, int QoS) throws MqttsnException { + if(brokerService == null){ + throw new MqttsnException("brokerService not available to connection, receive will fail"); + } + brokerService.receive(topicPath, payload, QoS); + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/AbstractMqttsnBrokerService.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/AbstractMqttsnBrokerService.java new file mode 100644 index 00000000..907d72b3 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/AbstractMqttsnBrokerService.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl.broker; + +import org.slj.mqtt.sn.gateway.spi.*; +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerConnection; +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerService; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerException; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerOptions; +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewayRuntimeRegistry; +import org.slj.mqtt.sn.impl.AbstractMqttsnBackoffThreadService; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.MqttsnRuntimeException; + +import java.util.logging.Level; + +public abstract class AbstractMqttsnBrokerService + extends AbstractMqttsnBackoffThreadService implements IMqttsnBrokerService { + + protected MqttsnBrokerOptions options; + + public AbstractMqttsnBrokerService(MqttsnBrokerOptions options){ + this.options = options; + } + + @Override + public void start(IMqttsnGatewayRuntimeRegistry runtime) throws MqttsnException { + super.start(runtime); + validateBrokerConnectionDetails(); + if(options.getConnectOnStartup()){ + logger.log(Level.INFO, "connect during startup requested.."); + try { + getBrokerConnection(null); + } catch(MqttsnBrokerException e){ + logger.log(Level.SEVERE, "encountered error attempting broker connect..", e); + throw new MqttsnException("encountered error attempting broker connect..",e); + } + logger.log(Level.INFO, "connection complete, broker service ready."); + } + } + + protected void validateBrokerConnectionDetails(){ + if(!options.validConnectionDetails()){ + throw new MqttsnRuntimeException("invalid broker connection details!"); + } + } + + @Override + public ConnectResult connect(IMqttsnContext context, String clientId, boolean cleanSession, int keepAlive) throws MqttsnBrokerException { + IMqttsnBrokerConnection connection = getBrokerConnection(context); + if(!connection.isConnected()){ + throw new MqttsnBrokerException("underlying broker connection was not connected"); + } + boolean success = connection.connect(context, cleanSession, keepAlive); + return new ConnectResult(success ? Result.STATUS.SUCCESS : Result.STATUS.ERROR, success ? "connection success" : "connection refused by broker side"); + } + + @Override + public DisconnectResult disconnect(IMqttsnContext context, int keepAlive) throws MqttsnBrokerException { + IMqttsnBrokerConnection connection = getBrokerConnection(context); + if(!connection.isConnected()){ + throw new MqttsnBrokerException("underlying broker connection was not connected"); + } + boolean success = connection.disconnect(context, keepAlive); + return new DisconnectResult(success ? Result.STATUS.SUCCESS : Result.STATUS.ERROR, success ? "disconnection success" : "disconnection refused by broker side"); + } + + @Override + public PublishResult publish(IMqttsnContext context, String topicPath, int QoS, byte[] payload) throws MqttsnBrokerException { + IMqttsnBrokerConnection connection = getBrokerConnection(context); + if(!connection.isConnected()){ + throw new MqttsnBrokerException("underlying broker connection was not connected"); + } + boolean success = connection.publish(context, topicPath, QoS, false, payload); + return new PublishResult(success ? Result.STATUS.SUCCESS : Result.STATUS.ERROR, success ? "publish success" : "publish refused by broker side"); + } + + @Override + public SubscribeResult subscribe(IMqttsnContext context, String topicPath, int QoS) throws MqttsnBrokerException { + IMqttsnBrokerConnection connection = getBrokerConnection(context); + if(!connection.isConnected()){ + throw new MqttsnBrokerException("underlying broker connection was not connected"); + } + boolean success = connection.subscribe(context, topicPath, QoS); + SubscribeResult res = new SubscribeResult(success ? Result.STATUS.SUCCESS : Result.STATUS.ERROR); + if(success) res.setGrantedQoS(QoS); + return res; + } + + @Override + public UnsubscribeResult unsubscribe(IMqttsnContext context, String topicPath) throws MqttsnBrokerException { + IMqttsnBrokerConnection connection = getBrokerConnection(context); + if(!connection.isConnected()){ + throw new MqttsnBrokerException("underlying broker connection was not connected"); + } + boolean success = connection.unsubscribe(context, topicPath); + return new UnsubscribeResult(success ? Result.STATUS.SUCCESS : Result.STATUS.ERROR); + } + + protected IMqttsnBrokerConnection getBrokerConnection(IMqttsnContext context) throws MqttsnBrokerException{ + synchronized (this){ + IMqttsnBrokerConnection connection = getBrokerConnectionInternal(context); + if(!connection.isConnected()){ + throw new MqttsnBrokerException("underlying broker connection was not connected"); + } + return connection; + } + } + + @Override + public void receive(String topicPath, byte[] payload, int QoS) throws MqttsnException { + registry.getGatewaySessionService().receiveToSessions(topicPath, payload, QoS); + } + + protected abstract void close(IMqttsnBrokerConnection connection) throws MqttsnBrokerException; + + protected abstract IMqttsnBrokerConnection getBrokerConnectionInternal(IMqttsnContext context) throws MqttsnBrokerException; +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/MqttsnAggregatingBrokerService.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/MqttsnAggregatingBrokerService.java new file mode 100644 index 00000000..f8d2389e --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/broker/MqttsnAggregatingBrokerService.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl.broker; + +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerConnection; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerException; +import org.slj.mqtt.sn.gateway.spi.broker.MqttsnBrokerOptions; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.util.logging.Level; + +/** + * A single broker connection is maintained and used for all connecting gateway side + * devices + */ +public class MqttsnAggregatingBrokerService extends AbstractMqttsnBrokerService { + + volatile IMqttsnBrokerConnection connection; + volatile boolean stopped = false; + + public MqttsnAggregatingBrokerService(MqttsnBrokerOptions options){ + super(options); + } + + @Override + public void stop() throws MqttsnException { + stopped = true; + super.stop(); + try { + close(connection); + } catch(MqttsnBrokerException e){ + logger.log(Level.WARNING, "error encountered shutting down broker connection;", e); + } + } + + @Override + protected void initThread() { + //-- only start deamon process if we are managing the connections + if(options.getManagedConnections()){ + super.initThread(); + } + } + + @Override + public boolean isConnected(IMqttsnContext context) throws MqttsnBrokerException { + return !stopped && connection != null && connection.isConnected(); + } + + @Override + protected long doWork() { + try { + if(options.getManagedConnections()){ + logger.log(Level.FINE, "checking status of managed connection.."); + if(connection != null){ + if(!connection.isConnected()){ + logger.log(Level.WARNING, "detected invalid connection to broker, dropping stale connection."); + close(connection); + } + } else { + initConnection(); + } + } else { + if(options.getConnectOnStartup() && connection == null){ + initConnection(); + } + } + } catch(Exception e){ + logger.log(Level.SEVERE, "error occurred monitoring connections;", e); + } + + return 10000; + } + + @Override + protected IMqttsnBrokerConnection getBrokerConnectionInternal(IMqttsnContext context) throws MqttsnBrokerException { + if(stopped) throw new MqttsnBrokerException("broker service is in the process or shutting down"); + initConnection(); + return connection; + } + + protected void initConnection() throws MqttsnBrokerException { + if(connection == null){ + //-- in aggregation mode connect with the gatewayId as the clientId on the broker side + synchronized (this){ + if(connection == null){ + connection = registry.getBrokerConnectionFactory().createConnection(options, + registry.getOptions().getContextId()); + //-- bootstrap to recieve events from the connections.. + //-- TODO - I dont like this pattern + if(connection instanceof AbstractMqttsnBrokerConnection){ + ((AbstractMqttsnBrokerConnection)connection).setBrokerService(this); + } + } + } + } + } + + @Override + protected void close(IMqttsnBrokerConnection connection) throws MqttsnBrokerException { + if(connection != null && connection.isConnected()){ + connection.close(); + } + this.connection = null; + } + + @Override + protected String getDaemonName() { + return "gateway-broker-managed-connection"; + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayAdvertiseService.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayAdvertiseService.java new file mode 100644 index 00000000..72895b81 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayAdvertiseService.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl.gateway; + +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewayAdvertiseService; +import org.slj.mqtt.sn.gateway.spi.gateway.MqttsnGatewayOptions; +import org.slj.mqtt.sn.impl.AbstractMqttsnBackoffThreadService; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.util.logging.Level; + +public class MqttsnGatewayAdvertiseService extends AbstractMqttsnBackoffThreadService implements IMqttsnGatewayAdvertiseService { + + long lastGatewayBroadcastTime; + + public synchronized void start(IMqttsnRuntimeRegistry runtime) throws MqttsnException { + if(runtime.getOptions().isEnableDiscovery()){ + super.start(runtime); + } + } + + @Override + protected long doWork() { + int timeout = 0; + try { + timeout = ((MqttsnGatewayOptions)registry.getOptions()).getGatewayAdvertiseTime(); + int gatewayId = ((MqttsnGatewayOptions) registry.getOptions()).getGatewayId(); + + logger.log(Level.INFO, String.format("advertising gateway id [%s], next sending time in [%s] seconds", + gatewayId, timeout)); + + IMqttsnMessage msg = registry.getMessageFactory().createAdvertise( + ((MqttsnGatewayOptions) registry.getOptions()).getGatewayId(), + timeout); + registry.getTransport().broadcast(msg); + lastGatewayBroadcastTime = System.currentTimeMillis(); + + } catch(Exception e){ + logger.log(Level.WARNING, String.format("error sending advertising message"), e); + } + + return timeout * 1000; + } + + @Override + protected String getDaemonName() { + return "gateway-advertise"; + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayMessageHandler.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayMessageHandler.java new file mode 100644 index 00000000..b910da6f --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayMessageHandler.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl.gateway; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.codec.MqttsnCodecException; +import org.slj.mqtt.sn.gateway.spi.*; +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewayRuntimeRegistry; +import org.slj.mqtt.sn.gateway.spi.gateway.MqttsnGatewayOptions; +import org.slj.mqtt.sn.impl.AbstractMqttsnMessageHandler; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.IMqttsnSessionState; +import org.slj.mqtt.sn.model.MqttsnClientState; +import org.slj.mqtt.sn.model.TopicInfo; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.utils.MqttsnUtils; +import org.slj.mqtt.sn.wire.MqttsnWireUtils; +import org.slj.mqtt.sn.wire.version1_2.payload.*; + +import java.util.Set; +import java.util.logging.Level; + +public class MqttsnGatewayMessageHandler + extends AbstractMqttsnMessageHandler { + + protected IMqttsnSessionState getSessionState(IMqttsnContext context) throws MqttsnException, MqttsnInvalidSessionStateException { + IMqttsnSessionState state = registry.getGatewaySessionService().getSessionState(context, false); + if(state == null || state.getClientState() == MqttsnClientState.DISCONNECTED) + throw new MqttsnInvalidSessionStateException("session not available for context"); + return state; + } + + protected IMqttsnSessionState getSessionState(IMqttsnContext context, boolean createIfNotExists) throws MqttsnException { + IMqttsnSessionState state = registry.getGatewaySessionService().getSessionState(context, createIfNotExists); + return state; + } + + @Override + protected void beforeHandle(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException { + + try { + //see if the client has an active sessionx + getSessionState(context); + } catch(MqttsnInvalidSessionStateException e){ + + //if they do NOT, the only time we can process messages + //on their behalf is if its a CONNECT or a PUBLISH M 1 + boolean shouldContinue = false; + if(message instanceof MqttsnConnect){ + //this is ok + shouldContinue = true; + } else if(message instanceof MqttsnPublish){ + MqttsnPublish p = (MqttsnPublish) message; + if(p.getQoS() == MqttsnConstants.QoSM1){ + //this is ok + shouldContinue = true; + } + } else if(message instanceof MqttsnDisconnect){ + shouldContinue = true; + } + if(!shouldContinue){ + logger.log(Level.WARNING, String.format("detected invalid client session state for [%s] and inbound message [%s]", context, message)); + throw new MqttsnException(e); + } + } + } + + @Override + protected void afterHandle(IMqttsnContext context, IMqttsnMessage messageIn, IMqttsnMessage messageOut) throws MqttsnException { + + super.afterHandle(context, messageIn, messageOut); + + try { + IMqttsnSessionState sessionState = getSessionState(context); + if(sessionState != null){ + registry.getGatewaySessionService().updateLastSeen(sessionState); + } + } catch(MqttsnInvalidSessionStateException e){ + //-- a disconnect will mean theres no session to update + } + } + + @Override + protected void afterResponse(IMqttsnContext context, IMqttsnMessage messageIn, IMqttsnMessage messageOut) throws MqttsnException { + + try { + IMqttsnSessionState sessionState = getSessionState(context); + if(sessionState != null){ + //active session means we can try and see if there is anything to flush here if its a terminal message + if(messageIn != null && isTerminalMessage(messageIn) && !messageIn.isErrorMessage() || + messageOut != null && isTerminalMessage(messageOut) && !messageOut.isErrorMessage() ){ + if(MqttsnUtils.in(sessionState.getClientState(), + MqttsnClientState.CONNECTED, MqttsnClientState.AWAKE)) { + registry.getMessageStateService().scheduleFlush(context); + } + } + } + } catch(MqttsnInvalidSessionStateException e){ + //-- a disconnect will mean theres no session to update + } + } + + @Override + protected IMqttsnMessage handleConnect(IMqttsnContext context, IMqttsnMessage connect) throws MqttsnException, MqttsnCodecException { + + MqttsnConnect connectMessage = (MqttsnConnect) connect ; + if(registry.getPermissionService() != null){ + if(!registry.getPermissionService().allowConnect(context, connectMessage.getClientId())){ + logger.log(Level.WARNING, String.format("permission service rejected client [%s]", connectMessage.getClientId())); + return registry.getMessageFactory().createConnack(MqttsnConstants.RETURN_CODE_SERVER_UNAVAILABLE); + } + } + + IMqttsnSessionState state = getSessionState(context, true); + ConnectResult result = registry.getGatewaySessionService().connect(state, connectMessage.getClientId(), + connectMessage.getDuration(), connectMessage.isCleanSession()); + + processSessionResult(result); + if(result.isError()){ + return registry.getMessageFactory().createConnack(result.getReturnCode()); + } + else { + if(connectMessage.isWill()){ + return registry.getMessageFactory().createWillTopicReq(); + } else { + return registry.getMessageFactory().createConnack(result.getReturnCode()); + } + } + } + + @Override + protected IMqttsnMessage handleDisconnect(IMqttsnContext context, IMqttsnMessage initialDisconnect, IMqttsnMessage receivedDisconnect) throws MqttsnException, MqttsnCodecException, MqttsnInvalidSessionStateException { + + MqttsnDisconnect d = (MqttsnDisconnect) receivedDisconnect; + if(!MqttsnUtils.validUInt16(d.getDuration())){ + logger.log(Level.WARNING, String.format("invalid sleep duration specified, reject client [%s]", d.getDuration())); + return super.handleDisconnect(context, initialDisconnect, receivedDisconnect); + } else { + IMqttsnSessionState state = getSessionState(context, false); + //was disconnected already? + + boolean needsResponse = initialDisconnect != null || + (state != null && !MqttsnUtils.in(state.getClientState(), MqttsnClientState.DISCONNECTED)); + if(state != null && !MqttsnUtils.in(state.getClientState(), MqttsnClientState.DISCONNECTED)){ + registry.getGatewaySessionService().disconnect(state, d.getDuration()); + } + return needsResponse ? + super.handleDisconnect(context, initialDisconnect, receivedDisconnect) : null; + } + } + + @Override + protected IMqttsnMessage handlePingreq(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException, MqttsnCodecException, MqttsnInvalidSessionStateException { + + IMqttsnSessionState state = getSessionState(context); + MqttsnPingreq ping = (MqttsnPingreq) message; + if(ping.getClientId() != null){ + //-- ensure the clientid matches the context + if(!ping.getClientId().trim().equals(context.getId())){ + logger.log(Level.WARNING, "ping-req contained clientId that did not match that from context"); + return super.handlePingreq(context, message); + } + } + + if(MqttsnUtils.in(state.getClientState(), MqttsnClientState.ASLEEP, MqttsnClientState.AWAKE)){ + //-- only wake the client if there is messages outstanding + if(registry.getMessageQueue().size(context) > 0){ + if(state.getClientState() == MqttsnClientState.ASLEEP){ + //-- this is the waking ping.. all is ok + registry.getGatewaySessionService().wake(state); + registry.getMessageStateService().scheduleFlush(context); + + } else if(state.getClientState() == MqttsnClientState.AWAKE){ + //-- this is the client issuing multiple pings when it should be waiting on the messages.. humph + logger.log(Level.INFO, "multiple pings are being sent, clear up and try again the client is getting confused.."); + registry.getMessageStateService().clearInflight(context); + registry.getMessageStateService().scheduleFlush(context); + } + + return null; + } else { + return super.handlePingreq(context, message); + } + } else { + registry.getGatewaySessionService().ping(state); + return super.handlePingreq(context, message); + } + } + + @Override + protected IMqttsnMessage handleSubscribe(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException, MqttsnCodecException, MqttsnInvalidSessionStateException { + + MqttsnSubscribe subscribe = (MqttsnSubscribe) message; + if(!MqttsnUtils.validTopicScheme(subscribe.getTopicType(), subscribe.getTopicData(), true)){ + logger.log(Level.WARNING, String.format("supplied topic did not appear to be valid, return INVALID TOPIC ID typeId [%s] topicData [%s]", subscribe.getTopicType(), + MqttsnWireUtils.toBinary(subscribe.getTopicData()))); + return registry.getMessageFactory().createSuback(0, 0, MqttsnConstants.RETURN_CODE_INVALID_TOPIC_ID); + } + + IMqttsnSessionState state = getSessionState(context); + TopicInfo info = registry.getTopicRegistry().normalize((byte) subscribe.getTopicType(), subscribe.getTopicData(), + subscribe.getTopicType() == 0); + + + SubscribeResult result = registry.getGatewaySessionService().subscribe(state, info, subscribe.getQoS()); + logger.log(Level.INFO, "subscribe message yeilded info " + info + " and result " + result); + processSessionResult(result); + if(result.isError()){ + return registry.getMessageFactory().createSuback(0, 0, result.getReturnCode()); + } else { + //-- this is a flaw in the current spec, you should be able to send back the topicIdType in the response + IMqttsnMessage suback = registry.getMessageFactory().createSuback(result.getGrantedQoS(), result.getTopicInfo().getTopicId(), result.getReturnCode()); + return suback; + } + } + + @Override + protected IMqttsnMessage handleUnsubscribe(IMqttsnContext context, IMqttsnMessage message) throws MqttsnException, MqttsnCodecException, MqttsnInvalidSessionStateException { + + MqttsnUnsubscribe unsubscribe = (MqttsnUnsubscribe) message; + + if(!MqttsnUtils.validTopicScheme(unsubscribe.getTopicType(), unsubscribe.getTopicData(), true)){ + logger.log(Level.WARNING, String.format("supplied topic did not appear to be valid, return INVALID TOPIC ID typeId [%s] topicData [%s]", unsubscribe.getTopicType(), + MqttsnWireUtils.toBinary(unsubscribe.getTopicData()))); + return registry.getMessageFactory().createUnsuback(); + } + + IMqttsnSessionState state = getSessionState(context); + TopicInfo info = registry.getTopicRegistry().normalize((byte) unsubscribe.getTopicType(), unsubscribe.getTopicData(), true); + UnsubscribeResult result = registry.getGatewaySessionService().unsubscribe(state, info); + processSessionResult(result); + return registry.getMessageFactory().createUnsuback(); + } + + @Override + protected IMqttsnMessage handleRegister(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException, MqttsnCodecException, MqttsnInvalidSessionStateException { + + MqttsnRegister register = (MqttsnRegister) message; + + if(!MqttsnUtils.validTopicName(register.getTopicName())){ + logger.log(Level.WARNING, + String.format("invalid topic [%s] received during register, reply with error code", register.getTopicName())); + return registry.getMessageFactory().createRegack(0, MqttsnConstants.RETURN_CODE_INVALID_TOPIC_ID); + } else { + IMqttsnSessionState state = getSessionState(context); + RegisterResult result = registry.getGatewaySessionService().register(state, register.getTopicName()); + processSessionResult(result); + return registry.getMessageFactory().createRegack(result.getTopicInfo().getTopicId(), MqttsnConstants.RETURN_CODE_ACCEPTED); + } + } + + @Override + protected IMqttsnMessage handlePublish(IMqttsnContext context, IMqttsnMessage message) + throws MqttsnException, MqttsnCodecException, MqttsnInvalidSessionStateException { + + MqttsnPublish publish = (MqttsnPublish) message; + IMqttsnSessionState state = null; + try { + state = getSessionState(context); + } catch(MqttsnInvalidSessionStateException e){ + //-- connectionless publish (m1) + if(publish.getQoS() != MqttsnConstants.QoSM1) + throw e; + } + + return super.handlePublish(context, message); + } + + protected void processSessionResult(Result result){ + if(result.getStatus() == Result.STATUS.ERROR){ + logger.log(Level.WARNING, String.format("error detected by session service [%s]", result)); + } + } +} \ No newline at end of file diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayPermissionService.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayPermissionService.java new file mode 100644 index 00000000..efed5ad3 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayPermissionService.java @@ -0,0 +1,39 @@ +package org.slj.mqtt.sn.gateway.impl.gateway; + +import org.slj.mqtt.sn.gateway.spi.gateway.MqttsnGatewayOptions; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.IMqttsnPermissionService; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.MqttsnService; + +import java.util.Set; + +public class MqttsnGatewayPermissionService + extends MqttsnService implements IMqttsnPermissionService { + + @Override + public boolean allowConnect(IMqttsnContext context, String clientId) throws MqttsnException { + Set allowedClientId = ((MqttsnGatewayOptions)registry.getOptions()).getAllowedClientIds(); + if(allowedClientId != null && !allowedClientId.isEmpty()){ + return allowedClientId.contains(MqttsnGatewayOptions.DEFAULT_CLIENT_ALLOWED_ALL) + || allowedClientId.contains(clientId); + } + return false; + } + + @Override + public boolean allowedToSubscribe(IMqttsnContext context, String topicPath) throws MqttsnException { + return true; + } + + @Override + public int allowedMaximumQoS(IMqttsnContext context, String topicPath) throws MqttsnException { + return 2; + } + + @Override + public boolean allowedToPublish(IMqttsnContext context, String topicPath, int size, int QoS) throws MqttsnException { + return true; + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayQueueProcessorStateService.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayQueueProcessorStateService.java new file mode 100644 index 00000000..75b86c20 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewayQueueProcessorStateService.java @@ -0,0 +1,40 @@ +package org.slj.mqtt.sn.gateway.impl.gateway; + +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewayRuntimeRegistry; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.IMqttsnSessionState; +import org.slj.mqtt.sn.model.MqttsnClientState; +import org.slj.mqtt.sn.spi.IMqttsnMessage; +import org.slj.mqtt.sn.spi.IMqttsnQueueProcessorStateService; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.spi.MqttsnService; +import org.slj.mqtt.sn.utils.MqttsnUtils; + +import java.util.logging.Level; + +public class MqttsnGatewayQueueProcessorStateService extends MqttsnService + implements IMqttsnQueueProcessorStateService { + + @Override + public boolean canReceive(IMqttsnContext context) throws MqttsnException { + IMqttsnSessionState state = getRegistry().getGatewaySessionService().getSessionState(context, false); + return state != null && MqttsnUtils.in(state.getClientState() , MqttsnClientState.CONNECTED, MqttsnClientState.AWAKE); + } + + @Override + public void queueEmpty(IMqttsnContext context) throws MqttsnException { + + IMqttsnSessionState state = getRegistry().getGatewaySessionService().getSessionState(context, false); + logger.log(Level.INFO, String.format("notified that the queue is empty, post process state is - [%s]", state)); + if(state != null){ + if(MqttsnUtils.in(state.getClientState() , MqttsnClientState.AWAKE)){ + logger.log(Level.INFO, String.format("notified that the queue is empty, putting device back to sleep and sending ping-resp - [%s]", context)); + //-- need to transition the device back to sleep + getRegistry().getGatewaySessionService().disconnect(state, state.getKeepAlive()); + //-- need to send the closing ping-resp + IMqttsnMessage pingResp = getRegistry().getMessageFactory().createPingresp(); + getRegistry().getTransport().writeToTransport(getRegistry().getNetworkRegistry().getContext(context), pingResp); + } + } + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewaySessionService.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewaySessionService.java new file mode 100644 index 00000000..f3c33bad --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/impl/gateway/MqttsnGatewaySessionService.java @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.impl.gateway; + +import org.slj.mqtt.sn.MqttsnConstants; +import org.slj.mqtt.sn.gateway.spi.*; +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewayRuntimeRegistry; +import org.slj.mqtt.sn.gateway.spi.gateway.IMqttsnGatewaySessionRegistryService; +import org.slj.mqtt.sn.gateway.spi.gateway.MqttsnGatewayOptions; +import org.slj.mqtt.sn.impl.AbstractMqttsnBackoffThreadService; +import org.slj.mqtt.sn.model.*; +import org.slj.mqtt.sn.spi.MqttsnException; +import org.slj.mqtt.sn.utils.TopicPath; + +import java.util.*; +import java.util.logging.Level; + +public class MqttsnGatewaySessionService extends AbstractMqttsnBackoffThreadService + implements IMqttsnGatewaySessionRegistryService { + + protected Map sessionLookup; + + @Override + public void start(IMqttsnGatewayRuntimeRegistry runtime) throws MqttsnException { + super.start(runtime); + sessionLookup = Collections.synchronizedMap(new HashMap()); + } + + @Override + protected long doWork() { + synchronized (sessionLookup){ + Iterator itr = sessionLookup.keySet().iterator(); + while(itr.hasNext()){ + IMqttsnContext context = itr.next(); + IMqttsnSessionState state = sessionLookup.get(context); + deamon_validateKeepAlive(state); + } + } + return 10000; + } + + protected void deamon_validateKeepAlive(IMqttsnSessionState state){ + if(state.getClientState() == MqttsnClientState.CONNECTED || + state.getClientState() == MqttsnClientState.ASLEEP){ + long time = System.currentTimeMillis(); + if(state != null && state.getKeepAlive() > 0){ + long lastSeen = state.getLastSeen().getTime(); + if(lastSeen + (state.getKeepAlive() * 1000) < time){ + logger.log(Level.WARNING, String.format("keep-alive deamon detected stale session for [%s], disconnecting", state.getContext())); + state.setClientState(MqttsnClientState.DISCONNECTED); + } + } + } + } + + @Override + public IMqttsnSessionState getSessionState(IMqttsnContext context, boolean createIfNotExists) { + IMqttsnSessionState state = sessionLookup.get(context); + if(state == null && createIfNotExists){ + synchronized (this){ + if((state = sessionLookup.get(context)) == null){ + state = new MqttsnSessionState(context, MqttsnClientState.PENDING); + sessionLookup.put(context, state); + } + } + } + return state; + } + + @Override + public ConnectResult connect(IMqttsnSessionState state, String clientId, int keepAlive, boolean cleanSession) throws MqttsnException { + ConnectResult result = null; + result = checkSessionSize(clientId); + if(result == null){ + synchronized (state.getContext()){ + try { + result = registry.getBrokerService().connect(state.getContext(), state.getContext().getId(), cleanSession, keepAlive); + } finally { + if(!result.isError()){ + //clear down all prior session state + cleanSession(state.getContext(), cleanSession); + state.setKeepAlive(keepAlive); + state.setClientState(MqttsnClientState.CONNECTED); + } else { + //-- connect was not successful ensure we + //-- do not hold a reference to any session + clear(state.getContext()); + } + } + } + } + + logger.log(Level.INFO, String.format("handled connection request for [%s] with cleanSession [%s] -> [%s], [%s]", state.getContext(), cleanSession, result.getStatus(), result.getMessage())); + return result; + } + + @Override + public void disconnect(IMqttsnSessionState state, int duration) throws MqttsnException { + DisconnectResult result = null; + synchronized (state.getContext()){ + result = registry.getBrokerService().disconnect(state.getContext(), duration); + if(!result.isError()){ + if(duration > 0){ + logger.log(Level.INFO, String.format("[%s] setting client state asleep for [%s]", state.getContext(), duration)); + state.setKeepAlive(duration); + state.setClientState(MqttsnClientState.ASLEEP); + registry.getTopicRegistry().clear(state.getContext(), + registry.getOptions().isSleepClearsRegistrations()); + } else { + logger.log(Level.INFO, String.format("[%s] disconnecting client", state.getContext())); + sessionLookup.remove(state.getContext()); + } + } + } + } + + @Override + public SubscribeResult subscribe(IMqttsnSessionState state, TopicInfo info, int QoS) throws MqttsnException { + + IMqttsnContext context = state.getContext(); + synchronized (context){ + String topicPath = null; + if(info.getType() == MqttsnConstants.TOPIC_TYPE.PREDEFINED){ + topicPath = registry.getTopicRegistry().lookupPredefined(context, info.getTopicId()); + info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.PREDEFINED, info.getTopicId()); + } else { + topicPath = info.getTopicPath(); + if(!TopicPath.isValidSubscription(topicPath, registry.getOptions().getMaxTopicLength())){ + return new SubscribeResult(Result.STATUS.ERROR, MqttsnConstants.RETURN_CODE_INVALID_TOPIC_ID, + "invalid topic format"); + } + if(!TopicPath.isWild(topicPath)){ + TopicInfo lookupInfo = registry.getTopicRegistry().lookup(state.getContext(), topicPath); + if(lookupInfo == null || info.getType() == MqttsnConstants.TOPIC_TYPE.NORMAL){ + info = registry.getTopicRegistry().register(state.getContext(), topicPath); + } + } else { + info = TopicInfo.WILD; + } + } + + if(topicPath == null){ + + //-- topic could not be found to lookup + return new SubscribeResult(Result.STATUS.ERROR, MqttsnConstants.RETURN_CODE_INVALID_TOPIC_ID, + "no topic found by specification"); + + } else { + if(registry.getPermissionService() != null){ + if(!registry.getPermissionService().allowedToSubscribe(context, topicPath)){ + return new SubscribeResult(Result.STATUS.ERROR, MqttsnConstants.RETURN_CODE_REJECTED_CONGESTION, + "permission service denied subscription"); + } + QoS = Math.min(registry.getPermissionService().allowedMaximumQoS(context, topicPath), QoS); + } + + if(registry.getSubscriptionRegistry().subscribe(state.getContext(), topicPath, QoS)){ + SubscribeResult result = registry.getBrokerService().subscribe(context, topicPath, QoS); + result.setTopicInfo(info); + return result; + } else { + SubscribeResult result = new SubscribeResult(Result.STATUS.NOOP); + result.setTopicInfo(info); + result.setGrantedQoS(QoS); + return result; + } + } + } + } + + @Override + public UnsubscribeResult unsubscribe(IMqttsnSessionState state, TopicInfo info) throws MqttsnException { + + IMqttsnContext context = state.getContext(); + synchronized (context){ + String topicPath = null; + if(info.getType() == MqttsnConstants.TOPIC_TYPE.PREDEFINED){ + topicPath = registry.getTopicRegistry().lookupPredefined(context, info.getTopicId()); + info = new TopicInfo(MqttsnConstants.TOPIC_TYPE.PREDEFINED, info.getTopicId()); + } else { + topicPath = info.getTopicPath(); + if(!TopicPath.isValidSubscription(topicPath, registry.getOptions().getMaxTopicLength())){ + return new UnsubscribeResult(Result.STATUS.ERROR, MqttsnConstants.RETURN_CODE_INVALID_TOPIC_ID, + "invalid topic format"); + } + if(!TopicPath.isWild(topicPath)){ + TopicInfo lookupInfo = registry.getTopicRegistry().lookup(state.getContext(), topicPath); + if(lookupInfo == null || info.getType() == MqttsnConstants.TOPIC_TYPE.NORMAL){ + info = registry.getTopicRegistry().register(state.getContext(), topicPath); + } + } else { + info = TopicInfo.WILD; + } + } + + if(topicPath == null){ + //-- topic could not be found to lookup + return new UnsubscribeResult(Result.STATUS.ERROR, MqttsnConstants.RETURN_CODE_INVALID_TOPIC_ID, + "no topic found by specification"); + } else { + if(registry.getSubscriptionRegistry().unsubscribe(context, topicPath)){ + UnsubscribeResult result = registry.getBrokerService().unsubscribe(context, topicPath); + return result; + } else { + return new UnsubscribeResult(Result.STATUS.NOOP); + } + } + } + } + + @Override + public RegisterResult register(IMqttsnSessionState state, String topicPath) throws MqttsnException { + + if(!TopicPath.isValidSubscription(topicPath, registry.getOptions().getMaxTopicLength())){ + return new RegisterResult(Result.STATUS.ERROR, MqttsnConstants.RETURN_CODE_INVALID_TOPIC_ID, "invalid topic format"); + } + synchronized (state.getContext()){ + TopicInfo info; + if(!TopicPath.isWild(topicPath)){ + info = registry.getTopicRegistry().lookup(state.getContext(), topicPath); + if(info == null){ + info = registry.getTopicRegistry().register(state.getContext(), topicPath); + } + } else { + info = TopicInfo.WILD; + } + return new RegisterResult(topicPath, info); + } + } + + @Override + public void ping(IMqttsnSessionState state) { + } + + @Override + public void wake(IMqttsnSessionState state) { + state.setClientState(MqttsnClientState.AWAKE); + } + + @Override + public void updateLastSeen(IMqttsnSessionState state) { + state.setLastSeen(new Date()); + } + + public void cleanSession(IMqttsnContext context, boolean deepClean) throws MqttsnException { + + //clear down all prior session state + synchronized (context){ + if(deepClean){ + //-- the queued messages + registry.getMessageQueue().clear(context); + + //-- the subscriptions + registry.getSubscriptionRegistry().clear(context); + } + + //-- inflight messages & protocol messages + registry.getMessageStateService().clear(context); + + //-- topic registrations + registry.getTopicRegistry().clear(context); + } + } + + public void clearAll() { + sessionLookup.clear(); + } + + @Override + public void clear(IMqttsnContext context) { + logger.log(Level.INFO, String.format(String.format("removing context from active/sleepings sessions [%s]", context))); + sessionLookup.remove(context); + } + + protected ConnectResult checkSessionSize(String clientId){ + + int maxConnectedClients = ((MqttsnGatewayOptions) registry.getOptions()).getMaxConnectedClients(); + if(sessionLookup.size() >= maxConnectedClients){ + return new ConnectResult(Result.STATUS.ERROR, MqttsnConstants.RETURN_CODE_REJECTED_CONGESTION, "gateway has reached capacity"); + } + return null; + } + + + + @Override + public void receiveToSessions(String topicPath, byte[] payload, int QoS) throws MqttsnException { + //-- expand the message onto the gateway connected device queues + List recipients = registry.getSubscriptionRegistry().matches(topicPath); + logger.log(Level.INFO, String.format("receiving broker side message into [%s] sessions", recipients.size())); + + //if we only have 1 reciever remove message after read + UUID messageId = recipients.size() > 1 ? + registry.getMessageRegistry().add(payload, calculateExpiry()) : + registry.getMessageRegistry().add(payload, true) ; + + for (IMqttsnContext client : recipients){ + int grantedQos = registry.getSubscriptionRegistry().getQos(client, topicPath); + int q = Math.min(grantedQos,QoS); + try { + registry.getMessageQueue().offer(client, new QueuedPublishMessage( + messageId, topicPath, q)); + } catch(MqttsnQueueAcceptException e){ + throw new MqttsnException(e); + } + } + } + + protected Date calculateExpiry(){ + Calendar cal = Calendar.getInstance(); + cal.setTime(new Date()); + cal.add(Calendar.YEAR, 1); + return cal.getTime(); + } + + @Override + protected String getDaemonName() { + return "gateway-session"; + } + + @Override + public Optional lookupClientIdSession(String clientId){ + synchronized (sessionLookup){ + Iterator itr = sessionLookup.keySet().iterator(); + while(itr.hasNext()){ + IMqttsnContext c = itr.next(); + if(c != null && c.getId().equals(clientId)) + return Optional.of(c); + } + } + return Optional.empty(); + } + + @Override + public Iterator iterator() { + Set copy = null; + synchronized (sessionLookup){ + Set s = sessionLookup.keySet(); + copy = new HashSet(s); + } + return copy.iterator(); + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/ConnectResult.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/ConnectResult.java new file mode 100644 index 00000000..09ce3ce9 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/ConnectResult.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi; + +public class ConnectResult extends Result { + + public ConnectResult(STATUS status, String message) { + super(status); + setMessage(message); + } + + public ConnectResult(STATUS status, int returnCode, String message) { + super(status); + setMessage(message); + setReturnCode(returnCode); + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/DisconnectResult.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/DisconnectResult.java new file mode 100644 index 00000000..2b5f849b --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/DisconnectResult.java @@ -0,0 +1,15 @@ +package org.slj.mqtt.sn.gateway.spi; + +public class DisconnectResult extends Result { + + public DisconnectResult(STATUS status, String message) { + super(status); + setMessage(message); + } + + public DisconnectResult(STATUS status, int returnCode, String message) { + super(status); + setMessage(message); + setReturnCode(returnCode); + } +} \ No newline at end of file diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/MqttsnInvalidSessionStateException.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/MqttsnInvalidSessionStateException.java new file mode 100644 index 00000000..020f9da0 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/MqttsnInvalidSessionStateException.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi; + +import org.slj.mqtt.sn.spi.MqttsnRuntimeException; + +public class MqttsnInvalidSessionStateException extends MqttsnRuntimeException { + + public MqttsnInvalidSessionStateException() { + } + + public MqttsnInvalidSessionStateException(String message) { + super(message); + } + + public MqttsnInvalidSessionStateException(String message, Throwable cause) { + super(message, cause); + } + + public MqttsnInvalidSessionStateException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/PublishResult.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/PublishResult.java new file mode 100644 index 00000000..0a2e6b7e --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/PublishResult.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi; + +public class PublishResult extends Result { + + public PublishResult(STATUS status, String message) { + super(status); + setMessage(message); + } + + public PublishResult(STATUS status, int returnCode, String message) { + super(status); + setMessage(message); + setReturnCode(returnCode); + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/RegisterResult.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/RegisterResult.java new file mode 100644 index 00000000..4bd1a404 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/RegisterResult.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi; + +import org.slj.mqtt.sn.model.TopicInfo; + +public class RegisterResult extends Result { + + private TopicInfo topicInfo; + private String topicPath; + + public RegisterResult(STATUS status) { + super(status); + } + + public RegisterResult(String topicPath, TopicInfo info) { + super(STATUS.SUCCESS); + this.topicInfo = info; + this.topicPath = topicPath; + } + + public RegisterResult(STATUS status, int returnCode, String message) { + super(status); + setMessage(message); + setReturnCode(returnCode); + } + + public String getTopicPath() { + return topicPath; + } + + public TopicInfo getTopicInfo() { + return topicInfo; + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/Result.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/Result.java new file mode 100644 index 00000000..d9c60705 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/Result.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi; + +import org.slj.mqtt.sn.MqttsnConstants; + +public class Result { + + protected STATUS status; + protected String message; + protected int returnCode; + + public enum STATUS { + SUCCESS, ERROR, NOOP + } + + public Result(STATUS status) { + this.status = status; + if(status == STATUS.SUCCESS){ + returnCode = MqttsnConstants.RETURN_CODE_ACCEPTED; + } else if (status == STATUS.ERROR){ + returnCode = MqttsnConstants.RETURN_CODE_SERVER_UNAVAILABLE; + } + } + + public Result(STATUS status, int returnCode) { + this.status = status; + this.returnCode = returnCode; + } + + public Result(STATUS status, int returnCode, String message) { + this.status = status; + this.returnCode = returnCode; + this.message = message; + } + + public STATUS getStatus() { + return status; + } + + public void setStatus(STATUS status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public int getReturnCode() { + return returnCode; + } + + public void setReturnCode(int returnCode) { + this.returnCode = returnCode; + } + + public boolean isError(){ + return STATUS.ERROR == status; + } + + @Override + public String toString() { + return "Result{" + + "status=" + status + + ", message='" + message + '\'' + + ", returnCode=" + returnCode + + '}'; + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/SubscribeResult.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/SubscribeResult.java new file mode 100644 index 00000000..3335d225 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/SubscribeResult.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi; + +import org.slj.mqtt.sn.model.TopicInfo; + +public class SubscribeResult extends Result { + + private TopicInfo topicInfo; + private int grantedQoS; + + public SubscribeResult(STATUS status, int returnCode, String message) { + super(status); + setMessage(message); + setReturnCode(returnCode); + } + + public SubscribeResult(STATUS status) { + super(status); + } + + public SubscribeResult(TopicInfo info, int grantedQoS) { + super(STATUS.SUCCESS); + this.topicInfo = info; + this.grantedQoS = grantedQoS; + } + + public TopicInfo getTopicInfo() { + return topicInfo; + } + + public void setTopicInfo(TopicInfo topicInfo) { + this.topicInfo = topicInfo; + } + + public int getGrantedQoS() { + return grantedQoS; + } + + @Override + public String toString() { + return "SubscribeResult{" + + "status=" + status + + ", message='" + message + '\'' + + ", returnCode=" + returnCode + + ", topicInfo=" + topicInfo + + ", grantedQoS=" + grantedQoS + + '}'; + } + + public void setGrantedQoS(int grantedQoS) { + this.grantedQoS = grantedQoS; + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/UnsubscribeResult.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/UnsubscribeResult.java new file mode 100644 index 00000000..a755b0f6 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/UnsubscribeResult.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi; + +public class UnsubscribeResult extends Result { + + public UnsubscribeResult(STATUS status){ + super(status); + } + + public UnsubscribeResult(STATUS status, String message) { + super(status); + setMessage(message); + } + + public UnsubscribeResult(STATUS status, int returnCode, String message) { + super(status); + setMessage(message); + setReturnCode(returnCode); + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerConnection.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerConnection.java new file mode 100644 index 00000000..696a5738 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerConnection.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi.broker; + +import org.slj.mqtt.sn.model.IMqttsnContext; + +import java.io.Closeable; + +public interface IMqttsnBrokerConnection extends Closeable { + + boolean isConnected() throws MqttsnBrokerException; + + void close(); + + boolean disconnect(IMqttsnContext context, int keepAlive) throws MqttsnBrokerException; + + boolean connect(IMqttsnContext context, boolean cleanSession, int keepAlive) throws MqttsnBrokerException; + + boolean subscribe(IMqttsnContext context, String topicPath, int QoS) throws MqttsnBrokerException; + + boolean unsubscribe(IMqttsnContext context, String topicPath) throws MqttsnBrokerException; + + boolean publish(IMqttsnContext context, String topicPath, int QoS, boolean retain, byte[] data) throws MqttsnBrokerException; +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerConnectionFactory.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerConnectionFactory.java new file mode 100644 index 00000000..1246313e --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerConnectionFactory.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi.broker; + +public interface IMqttsnBrokerConnectionFactory { + + T createConnection(MqttsnBrokerOptions options, String clientId) throws MqttsnBrokerException; +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerService.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerService.java new file mode 100644 index 00000000..d28e61e2 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/IMqttsnBrokerService.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi.broker; + +import org.slj.mqtt.sn.gateway.spi.*; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.spi.MqttsnException; + +public interface IMqttsnBrokerService { + + boolean isConnected(IMqttsnContext context) throws MqttsnBrokerException; + + ConnectResult connect(IMqttsnContext context, String clientId, boolean cleanSession, int keepAlive) throws MqttsnBrokerException; + + DisconnectResult disconnect(IMqttsnContext context, int keepAlive) throws MqttsnBrokerException ; + + PublishResult publish(IMqttsnContext context, String topicPath, int QoS, byte[] payload) throws MqttsnBrokerException; + + SubscribeResult subscribe(IMqttsnContext context, String topicPath, int QoS) throws MqttsnBrokerException; + + UnsubscribeResult unsubscribe(IMqttsnContext context, String topicPath) throws MqttsnBrokerException; + + void receive(String topicPath, byte[] payload, int QoS) throws MqttsnException; + +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/MqttsnBrokerException.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/MqttsnBrokerException.java new file mode 100644 index 00000000..7d77d516 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/MqttsnBrokerException.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi.broker; + +import org.slj.mqtt.sn.spi.MqttsnException; + +public class MqttsnBrokerException extends MqttsnException { + + public MqttsnBrokerException() { + } + + public MqttsnBrokerException(String message) { + super(message); + } + + public MqttsnBrokerException(String message, Throwable cause) { + super(message, cause); + } + + public MqttsnBrokerException(Throwable cause) { + super(cause); + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/MqttsnBrokerOptions.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/MqttsnBrokerOptions.java new file mode 100644 index 00000000..1886786e --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/broker/MqttsnBrokerOptions.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi.broker; + +import java.util.Objects; + +public final class MqttsnBrokerOptions { + + public static final boolean DEFAULT_CONNECT_ON_STARTUP = true; + public static final boolean DEFAULT_MANAGED_CONNECTIONS = true; + public static final int DEFAULT_KEEPALIVE = 30 * 10; + public static final int DEFAULT_CONNECTION_TIMEOUT = 30; + public static final int DEFAULT_MQTT_PORT = 1883; + public static final String DEFAULT_MQTT_PROTOCOL = "tcp"; + + private boolean connectOnStartup = DEFAULT_CONNECT_ON_STARTUP; + private boolean managedConnections = DEFAULT_MANAGED_CONNECTIONS; + private String username; + private String password; + private int keepAlive = DEFAULT_KEEPALIVE; + private int connectionTimeout = DEFAULT_CONNECTION_TIMEOUT; + private String host; + private int port = DEFAULT_MQTT_PORT; + private String protocol = DEFAULT_MQTT_PROTOCOL; + + public MqttsnBrokerOptions(){ + + } + + public MqttsnBrokerOptions withConnectOnStartup(boolean connectOnStartup){ + this.connectOnStartup = connectOnStartup; + return this; + } + + public MqttsnBrokerOptions withManagedConnections(boolean managedConnections){ + this.managedConnections = managedConnections; + return this; + } + + public MqttsnBrokerOptions withProtocol(String protocol){ + this.protocol = protocol; + return this; + } + + public MqttsnBrokerOptions withUsername(String username){ + this.username = username; + return this; + } + + public MqttsnBrokerOptions withPassword(String password){ + this.password = password; + return this; + } + + public MqttsnBrokerOptions withKeepAlive(int keepAlive){ + this.keepAlive = keepAlive; + return this; + } + + public MqttsnBrokerOptions withConnectionTimeout(int connectionTimeout){ + this.connectionTimeout = connectionTimeout; + return this; + } + + public MqttsnBrokerOptions withHost(String host){ + this.host = host; + return this; + } + + public MqttsnBrokerOptions withPort(int port){ + this.port = port; + return this; + } + + public boolean getManagedConnections() { + return managedConnections; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public int getKeepAlive() { + return keepAlive; + } + + public int getConnectionTimeout() { + return connectionTimeout; + } + + public String getHost() { + return host; + } + + public boolean getConnectOnStartup() { + return connectOnStartup; + } + + public int getPort() { + return port; + } + + public String getProtocol() { + return protocol; + } + + public boolean validConnectionDetails(){ + return !nonEmpty(protocol) && !nonEmpty(host) && port > 0 && !nonEmpty(username); + } + + static boolean nonEmpty(String val){ + return !Objects.isNull(val) && "".equals(val.trim()); + } +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewayAdvertiseService.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewayAdvertiseService.java new file mode 100644 index 00000000..e404031e --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewayAdvertiseService.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi.gateway; + +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; +import org.slj.mqtt.sn.spi.IMqttsnService; + +public interface IMqttsnGatewayAdvertiseService extends IMqttsnService { + + + +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewayRuntimeRegistry.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewayRuntimeRegistry.java new file mode 100644 index 00000000..1b9fef99 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewayRuntimeRegistry.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi.gateway; + +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerConnectionFactory; +import org.slj.mqtt.sn.gateway.spi.broker.IMqttsnBrokerService; +import org.slj.mqtt.sn.spi.IMqttsnRuntimeRegistry; + +public interface IMqttsnGatewayRuntimeRegistry extends IMqttsnRuntimeRegistry { + + IMqttsnBrokerService getBrokerService(); + + IMqttsnBrokerConnectionFactory getBrokerConnectionFactory(); + + IMqttsnGatewaySessionRegistryService getGatewaySessionService(); + + IMqttsnGatewayAdvertiseService getGatewayAdvertiseService(); + +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewaySessionRegistryService.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewaySessionRegistryService.java new file mode 100644 index 00000000..9c19b63c --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/IMqttsnGatewaySessionRegistryService.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi.gateway; + +import org.slj.mqtt.sn.gateway.spi.ConnectResult; +import org.slj.mqtt.sn.gateway.spi.RegisterResult; +import org.slj.mqtt.sn.gateway.spi.SubscribeResult; +import org.slj.mqtt.sn.gateway.spi.UnsubscribeResult; +import org.slj.mqtt.sn.model.IMqttsnContext; +import org.slj.mqtt.sn.model.IMqttsnSessionState; +import org.slj.mqtt.sn.model.TopicInfo; +import org.slj.mqtt.sn.spi.IMqttsnRegistry; +import org.slj.mqtt.sn.spi.MqttsnException; + +import java.util.Iterator; +import java.util.Optional; + +public interface IMqttsnGatewaySessionRegistryService extends IMqttsnRegistry { + + Optional lookupClientIdSession(String clientId) throws MqttsnException; + + IMqttsnSessionState getSessionState(IMqttsnContext context, boolean createIfNotExists) throws MqttsnException; + + ConnectResult connect(IMqttsnSessionState state, String clientId, int keepAlive, boolean cleanSession) throws MqttsnException; + + SubscribeResult subscribe(IMqttsnSessionState state, TopicInfo info, int QoS) throws MqttsnException; + + UnsubscribeResult unsubscribe(IMqttsnSessionState state, TopicInfo info) throws MqttsnException; + + RegisterResult register(IMqttsnSessionState state, String topicPath) throws MqttsnException; + + void wake(IMqttsnSessionState state) throws MqttsnException; + + void ping(IMqttsnSessionState state) throws MqttsnException; + + void updateLastSeen(IMqttsnSessionState state); + + void cleanSession(IMqttsnContext state, boolean deepClean) throws MqttsnException; + + void disconnect(IMqttsnSessionState state, int duration) throws MqttsnException; + + void receiveToSessions(String topicPath, byte[] payload, int QoS) throws MqttsnException ; + + Iterator iterator(); +} diff --git a/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/MqttsnGatewayOptions.java b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/MqttsnGatewayOptions.java new file mode 100644 index 00000000..b179e918 --- /dev/null +++ b/mqtt-sn-gateway/src/main/java/org/slj/mqtt/sn/gateway/spi/gateway/MqttsnGatewayOptions.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2020 Simon Johnson + * + * Find me on GitHub: + * https://github.com/simon622 + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.slj.mqtt.sn.gateway.spi.gateway; + +import org.slj.mqtt.sn.model.MqttsnOptions; + +import java.util.HashSet; +import java.util.Set; + +public final class MqttsnGatewayOptions extends MqttsnOptions { + + /** + * By default, any clientId ("*") will be allowed to connect to the gateway. + */ + public static final String DEFAULT_CLIENT_ALLOWED_ALL = "*"; + + /** + * The default gatewayId used in advertise / discovery is 1 + */ + public static final int DEFAULT_GATEWAY_ID = 1; + + /** + * A default gateway will allow 100 simultaneous connects to reside on the gateway + */ + public static final int DEFAULT_MAX_CONNECTED_CLIENTS = 100; + + /** + * The default advertise time is 60 seconds + */ + public static final int DEFAULT_GATEWAY_ADVERTISE_TIME = 60; + + private Set allowedClientIds = new HashSet(); + { + allowedClientIds.add(DEFAULT_CLIENT_ALLOWED_ALL); + } + + private int maxConnectedClients = DEFAULT_MAX_CONNECTED_CLIENTS; + private int gatewayAdvertiseTime = DEFAULT_GATEWAY_ADVERTISE_TIME; + private int gatewayId = DEFAULT_GATEWAY_ID; + + public MqttsnGatewayOptions withMaxConnectedClients(int maxConnectedClients){ + this.maxConnectedClients = maxConnectedClients; + return this; + } + + public MqttsnGatewayOptions withGatewayId(int gatewayId){ + this.gatewayId = gatewayId; + return this; + } + + public MqttsnGatewayOptions withGatewayAdvertiseTime(int gatewayAdvertiseTime){ + this.gatewayAdvertiseTime = gatewayAdvertiseTime; + return this; + } + + public int getGatewayAdvertiseTime() { + return gatewayAdvertiseTime; + } + + public int getGatewayId() { + return gatewayId; + } + + public int getMaxConnectedClients() { + return maxConnectedClients; + } + + public Set getAllowedClientIds() { + return allowedClientIds; + } + + public MqttsnGatewayOptions withAllowedClientId(String clientId){ + + //-- if the application specifies custom allow list, remove wildcard + if(allowedClientIds.contains(DEFAULT_CLIENT_ALLOWED_ALL)){ + allowedClientIds.remove(DEFAULT_CLIENT_ALLOWED_ALL); + } + allowedClientIds.add(clientId); + return this; + } +}

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