Package: birdsong
Birdsong - A Python interface to the Canary APIMake talking to Canary easyCanaryis a historian fromCanary Labs, andbirdsongis a library for interfacing with it via Python.Birdsong will take care of the details of dealing with REST calls, tokens, continuations, and other powerful low level features to let you focus on making Canary sing.Table of ContentsInstallationQuickstartUsageHelper structuresSending data to Canary:CanarySenderCreate new file:createNewFileCreate rollover file:fileRolloverStore data:storeDataViewing data in Canary:CanaryViewExploring Canary:browseNodesExploring tags:browseTagsGet node status:browseStatusOption values: translate quality values:getQualitiesOption values: get aggregate values:getAggregatesGet tag data:getTagDataGet tag properties:getTagPropertiesGetlivetag data:getLiveDataAdvanced usageContributingLicenseInstallationPipYou can use the packages on the PyPI via pip:python-mpipinstallbirdsong--upgradeManual installationCopy the contents of the Canary folder into yoursite-packagesfolder in your Pythonlibsfolder.
Depending on your environment, use the Git branch appropriate.IgnitionChoose the branch for the version of Ignition in use first. From there you may either:A) Copy the/birdsongdirectory from this repo into directly into Ignition's./user-lib/pylib/site-packages/directory.
On Windows systems this will likely beC:\Program Files\Inductive Automation\Ignition\user-lib\pylib\site-packagesB) Copy the contents of the all-in-one python file into the project library in the Ignition Designer.
That file will be named something likebirdsong.ignition_8.py.Once copied, the install can be tested with the./test/playground_test.pyfile in that branch.
(Change the import path if Birdsong was places somewhere other thansite-packagesor inProject Library/birdsong)Quickstart# get started so quick we don't even have time for text outside a code blockfrombirdsongimportCanarySender,CanaryView,TvqviewName='CS-Surface61'datasetName='Testing2'tagPath=datasetName+'.Quick Data!!!'withCanarySender(autoCreateDatasets=True)assender:sender.storeData({tagPath:[Tvq('2019-10-20 12:34Z',-666),Tvq('2019-10-20 15:20Z',999)]})withCanaryView()asview:print(next(view.getTagData(viewName+'.'+tagPath))){'timestamp': u'2019-10-20T15:20:00.0000000-07:00', 'value': 999}In this we:Imported the Canary interfaces and a convenient helperConnected to the localhost Canary Sender service on the default anonymous portStored two datapoints in theQuick Data!tag in theTesting2dataset (whether or notTestingalready existed - it doesnow)Connected to the localhost Canary View service on the default anonymous portGot data from theCS-Surface61view (the name my computer gave my historian)...... and immediately consumed the first entry returned via Python'snextkeyword...... and printed it to the console (which theTvqclass autoformatted to look like a dictionary)And after thewithstatements, Python exited the connections (firstviewthensender), revoking tokens as needed to free them up for others to use.Inside baseball note: if theviewwas initialized outside the sender service's context (thewithstatement), the value returned would have beenNone, since we didn't tell the sender service to setautoWriteNoData=False. Thus, once thewithends the sender would have automatically marked an end to the data transmission session via aNo Dataentry. Pass in the flag to suppress that, if desired.Important security notice:noVerifySSL=Falseby defaultBecause many Canary instances are on intranets and may or may not have certificates that are easily validated by a trusted central authority, SSL validation isOFF by default.You may keep warnings on if you setbirdsong.rest.VALIDATE_SSL_CERTS = Truebefore initializing a connection to Canary. You may also manually turn on cert validation by passing inverifySSL=TruetoCanaryViewandCanarySenderon initialization.UsageBy default, the connections will attach tolocalhostover an anonymous connection. If you're testingbirdsongon the same machine as a Canary instance, it'll try to connect to that first.Tokens will be aquired as needed. That means until a call is made that requires it, it won't request a user token. Further, it will keep trying to use the token until Canary throws an error on it; on token expiration, Canary will send an error and the interface class will automatically reaquire. You won't see the error other than a slight additional delay as the new token is reaquired.BothCanaryViewandCanarySenderare designed to automatically clean themselves up when they go out of scope. For best practice, use it in acontext managerOnce instantiated, it will make the HTTP REST calls to Canary with the right user/session/livedata tokens. If they expire, it'll renew them on the next call.For the purposes of the following examples, assume that Canary is running onlocalhost, and that machine is calledCS-Surface61. Examples of how to log in will be sprinkled throughout as well.
Also assume that there is a dataset calledTestingin which we'll be putting and pulling most of the data in the following examples.For demo purposes, we'll assume thatTestingis the dataset of choice, and our main view will beCS-Surface61:importrandom,stringhost='localhost'mainView='CS-Surface61'dataset='Testing'tagPaths=['.'.join([dataset,''.join([random.choice(string.ascii_uppercase)foriinrange(6)])])for_inrange(3)]print(tagPaths)['Testing.ADRLCO', 'Testing.XBCFZF', 'Testing.ITKORQ']For demo purposes, I'll be referencing these tags, unless stated otherwise.Helper structuresThree helper classes are provided:Tvq,Property, andAnnotation. These are all based on a class that allow these to be created with a bit of flexibility. Importantly, these will ensure values are sent to Canary in the expected order while leaving optional values out.Note: If Canary returns a date of0001-01-01T00:00:00.0000000this will be set on thetimestampfields asNone. It's a value returned under some circumstances (like requesting data for a nonexistent tag in a valid dataset), but because it's not a valid timebirdsonginterprets this to make sure it can't be confused for a normal datetime object.To generate an instance, pass in values either in order or by name:>>>tvq1=Tvq('2019-10-01 03:00:00',3)# quality is optional and assumed 192 GOOD>>>tvq2=Tvq('2019-10-01 12:34:56',999,216)>>>tvq1{'timestamp':'2019-10-01 03:00:00','value':3}>>>tvq2{'timestamp':'2019-10-01 12:34:56','quality':216,'value':999}>>>tvq1.qualityNone>>>tvq2.value999>>>tvq2.v999>>>tvq2['value']999>>>Tvq('0001-01-01T00:00:00.0000000-08:00',None){'timestamp':None,'value':None}The values for these are:Helper ClassAttributesTvqtimestamp,value,quality*Propertyname,timestamp,value,quality*Annotationuser,timestamp,value,createdAt**are optionalNote that these will attempt to convert the timestamp to anArrowdatetime object. It's just like a normaldatetimeobject, but a bit smarter and easier to manipulate. Combined withciso8601, this can quickly convert the timestamps to a highly flexible object.Each class has a settter likeTvq.setTimeFormat('...')that can be called in case something perverse like anon-ISO8601 date is parsed. Note that a timezone should be set. Canary returns results in a timezone sensitive way -beeverwary of naked timestamps, especially when searching, filtering, and storing data!Also note that once instantiated these areimmutible. These are meant to be treated as read-only since no mechanism to feed directly back on the process is available.Sending data to Canary:CanarySenderTheCanarySenderclass works just like how views are worked with.Create new file:createNewFileUse this to create a new file that's not linked to the previous. Provide it with the dataset that gets a new file and the timestamp to apply to the file.withCanarySender()assend:send.createNewFile('Testing','2019-10-01 00:00')Create rollover file:fileRolloverCreate a new file rolling over from the previous.withCanarySender()assend:send.fileRollover('Testing','2019-10-01 00:00')Store data:storeDataThestoreDatamethod logs both tvq values (time, value, quality) as well as properties and annotations. If there is any question about the tuples that should be sent to Canary, use the helper structs - these will be expanded correctly when sent.All inputs are dictionaries where the keys are Canary tag paths and the values are lists of entries.Storing data is as easy as making a dictionary of tags and a list of their TVQ entries.tvqDict={tagPaths[1]:[('2019-10-01 01:11:11',1.11),('2019-10-01 02:22:22',2.22,192),Tvq('2019-10-01 03:33:33',3.33),Tvq('2019-10-01 04:44:44',4.44,192),],tagPaths[2]:[('2019-10-01 01:00:00',1),('2019-10-01 02:00:00',2,192),Tvq('2019-10-01 03:00:00',3),Tvq('2019-10-01 04:00:00',4,192),]}withCanarySender()assend:send.storeData(tvqDict)Store properties like so:withCanarySender()assend:send.storeData(properties={tagPaths[0]:[['Some Property','10/01/2019 12:00','A property value']]})See thegetTagPropertiesexample for getting this back from the system.And annotations likewise:withCanarySender()assend:send.storeData(annotations={tagPaths[0]:[['SHODAN','11/7/2019 19:11','Passcode 711 missing']]})Viewing data in Canary:CanaryViewViews are how we look into the data Canary holds. The interfacebirdsongprovides is theCanaryViewclass.Pass in the following keywords toExploring Canary:browseNodesIf my main Canary instance is on my computer (namedCS-Surface61), thenbrowseNodeswill list thefrombirdsongimportCanaryViewwithCanaryView()asview:fornodeinview.browseNodes():print(node)Test Model
CS-Surface61Likewise, we can drill in to get the datasets under a view:withCanaryView()asview:fornodeinview.browseNodes('CS-Surface61'):print(node)Testing
{Diagnostics}Exploring tags:browseTagsA tag listing can be retrieved by callingbrowseTags. Thepathargument is the root node to search under, whilesearchwill narrow the results down to values matching the tag (much as the search works in Axiom). SetdeeptoTrueto recursively search a node.withCanaryView()asview:fortagPathinview.browseTags(path='CS-Surface61.Testing'):printtagPathCS-Surface61.Testing.ADRLCO
CS-Surface61.Testing.ITKORQ
CS-Surface61.Testing.XBCFZFMy computer happens to have another testing dataset, which shows up in the following:withCanaryView()asview:fortagPathinview.browseTags(path='CS-Surface61',search='Testing',deep=True):printtagPathCS-Surface61.Testing.BKYTXS
CS-Surface61.Testing.FEZWZR
CS-Surface61.Testing.OCEGGC
CS-Surface61.Testing.QIFAFZ
CS-Surface61.Testing.RWNNHP
CS-Surface61.Testing.Some.Tag.Path.CV
CS-Surface61.Testing2.Quick Data!!!!Get node status:browseStatusTo find out if a node has been updated, directly query it and check if the sequence number is different:>>>print(CanaryView().browseStatus('CS-Surface61'))or muliple views at once:withCanaryView()asview:forviewName,sequenceinview.browseStatus(['CS-Surface61','Test Model']):print('%s-%s'%(viewName,sequence))637078138670000000  -  CS-Surface61
637075536560000000  -  Test ModelOption values: translate quality values:getQualitiesUnless a returned value is192(Good), data is returned with a quality value. This is a value as enumerated by the OPC communication standard. Not everyone has all the values memorized, though, so you can look them up with this function.withCanaryView(host='localhost')asview:printview.getQualities('90'){u'90': u'Uncertain-Sub Normal-Limit High'}Or you can ask for more than one at a time (say from retrieved data)someDataQualities=[value.qualityforvalueinsomeDataifvalue.quality]withCanaryView()asview:printview.getQualities(someDataQualities){u'9': u'Bad-Not Connected-Limit Low', u'90': u'Uncertain-Sub Normal-Limit High', u'210': u'Good-Limit High'}Option values: get aggregate values:getAggregatesWhen getting data for a tag, you can setaggregateNameto one of the values given by this dictionary.withCanaryView()asview:print(sorted(view.getAggregates().keys()))[u'Average', u'Count', u'Delta', u'DeltaBounds', u'DurationBad', u'DurationGood', u'DurationInStateNonZero', u'DurationInStateZero', u'End', u'EndBound', u'Instant', u'Interpolative', u'Maximum', u'Maximum2', u'MaximumActualTime', u'MaximumActualTime2', u'Minimum', u'Minimum2', u'MinimumActualTime', u'MinimumActualTime2', u'NumberOfTransitions', u'PercentBad', u'PercentGood', u'Range', u'Range2', u'StandardDeviationPopulation', u'StandardDeviationSample', u'Start', u'StartBound', u'TimeAverage', u'TimeAverage2', u'Total', u'Total2', u'TotalPer24Hours', u'TotalPerHour', u'TotalPerMinute', u'VariancePopulation', u'VarianceSample', u'WorstQuality', u'WorstQuality2']Get tag data:getTagDataTo get the most recent value for a tag, simply callgetTagDatawith that tag's path:# Get the default value (most recent - may well be the No Data value)withCanaryView()asview:forvalueinview.getTagData('CS-Surface61.Testing.ADRLCO'):print(value){'timestamp': u'2019-10-01T04:56:12.0000001-07:00', 'value': None}Note: as we log data to Canary and close our sessions, Canary will assume the data stream has come to an end and bracket it withNo Data, which will show as aNonein our results here.To get the values for a tag between two time spans, simply pass in the constraints as arguments:# Get all values between datestagPath=mainView+'.'+dataset+'.'+'ADRLCO'withCanaryView()asview:forvalueinview.getTagData(tagPath,startTime='2019-10-01T00:00:00-0700',endTime='2019-10-01T03:00-0700'):print(value){'timestamp': u'2019-10-01T01:23:45.0000000-07:00', 'value': 1.23}
{'timestamp': u'2019-10-01T02:34:56.0000000-07:00', 'value': 2.34}Any of the constraints outlined in your Canary View's/helpendpoint will work.Getting data for more than one tag simply means passing in a list of tags.tagList=[mainView+'.'+tagPathfortagPathintagPaths[1:3]]# Get the default value (most recent - will be the No Data value)withCanaryView()asview:fortagPath,valuesinview.getTagData(tagList):print(tagPath)forvalueinvalues:print('\t%r'%value)CS-Surface61.Testing.XBCFZF
	{'timestamp': u'2019-10-01T04:44:44.0000001-07:00', 'value': None}
CS-Surface61.Testing.ITKORQ
	{'timestamp': u'2019-10-01T04:00:00.0000001-07:00', 'value': None}Pay special attention that the results match the input: if a tag path is given by itself, you'll get back an iterable of values. If a list of tags are given, you'll get back an iterable back of the tag paths and their values. (These will be in the same order given.)If a start and end time is given it will look like this:# Get the values for each tag between given dateswithCanaryView()asview:fortagPath,valuesinview.getTagData(tagList,start='2019-10-01T00:00:00-0700',end='2019-10-01T03:00-0700'):print(tagPath)forvalueinvalues:print('\t%r'%value)CS-Surface61.Testing.XBCFZF
	{'timestamp': u'2019-10-01T01:11:11.0000000-07:00', 'value': 1.11}
	{'timestamp': u'2019-10-01T02:22:22.0000000-07:00', 'value': 2.22}
CS-Surface61.Testing.ITKORQ
	{'timestamp': u'2019-10-01T01:00:00.0000000-07:00', 'value': 1}
	{'timestamp': u'2019-10-01T02:00:00.0000000-07:00', 'value': 2}Note thatstartandendwere used here. For convenience these are automatically translated to the naming convention Canary expects. (I caught myself writing the wrong suffix too much...)withCanaryView()asview:tagProps=view.getTagProperties('CS-Surface61.'+tagPaths[0])print(tagProps){u'Some Property': u'A property value'}Note that this result comes from the laterstoreDataroutine.Passing in a list results in a generator:tagList = ['CS-Surface61.' + tagPath for tagPath in tagPaths[:2]]
with CanaryView() as view:
    for tagPath, propDict in view.getTagProperties(tagList):
        print('%s - %r' % (tagPath, propDict))CS-Surface61.Testing.QIFAFZ - {u'Some Property': u'A property value'}
CS-Surface61.Testing.OCEGGC - {}Get tag properties:getTagPropertiesTag properties can be queried by thegetTagPropertiesfunction. This will return the most recent value set for each property for a tag.Like the othergetiterator methods, this will likewise return a dict object or a generator when a list of tag paths is provided.Getlivetag data:getLiveDataCanary provides a special API call for getting the most recent datasince the last time you askedin thegetLiveDatamethod. Birdsong will manage the token needed to take advantage of this.The easiest way to use it is like the regular tag data method:tagPath='.'.join([mainView,dataset,tagPaths[0]])withCanaryView()asview:forvalueinview.getLiveData(tagPath):print(value){'timestamp': u'2019-10-27T16:03:10.4280000-07:00', 'value': 0}This tag happens to have another thread pumping data in viaCanarySender().storeData(), so if we connect and check periodically we'll see additional updates as they come in:stepTime=3withCanaryView()asview:forstepinrange((testDuration//stepTime)+2):print('Update%d'%step)forvalueinview.getLiveData(tagPath):print(value)sleep(stepTime)Update 0
{'timestamp': u'2019-10-27T16:03:12.4490000-07:00', 'value': 100}
Update 1
{'timestamp': u'2019-10-27T16:03:14.4590000-07:00', 'value': 200}
{'timestamp': u'2019-10-27T16:03:16.4690000-07:00', 'value': 300}
Update 2
{'timestamp': u'2019-10-27T16:03:18.4740000-07:00', 'value': 400}
Update 3
{'timestamp': u'2019-10-27T16:03:18.4740001-07:00', 'value': None}Likewise, multiple tags can also be checked. For this example, we'll only be updating tags thataren't the first.# Check all tags so that we can see the first _not_ get updated in later callsviewQualifiedTagPaths=[mainView+'.'+tagPathfortagPathintagPaths]stepTime=3withCanaryView()asview:forstepinrange((testDuration//stepTime)+2):print('Update%d'%step)fortagPath,valuesinview.getLiveData(viewQualifiedTagPaths):print('\t%s'%tagPath)forvalueinvalues:print('\t\t%r'%value)sleep(stepTime)Update 0
	CS-Surface61.Testing.ITKORQ
		{'timestamp': u'2019-10-27T16:02:39.4040000-07:00', 'value': 1}
	CS-Surface61.Testing.ADRLCO
		{'timestamp': u'2019-10-27T16:00:52.1000001-07:00', 'value': None}
	CS-Surface61.Testing.XBCFZF
		{'timestamp': u'2019-10-27T16:02:39.4040000-07:00', 'value': 0}
Update 1
	CS-Surface61.Testing.ITKORQ
		{'timestamp': u'2019-10-27T16:02:41.4190000-07:00', 'value': 101}
	CS-Surface61.Testing.XBCFZF
		{'timestamp': u'2019-10-27T16:02:41.4190000-07:00', 'value': 100}
Update 2
	CS-Surface61.Testing.ITKORQ
		{'timestamp': u'2019-10-27T16:02:43.4390000-07:00', 'value': 201}
		{'timestamp': u'2019-10-27T16:02:45.4500000-07:00', 'value': 301}
	CS-Surface61.Testing.XBCFZF
		{'timestamp': u'2019-10-27T16:02:43.4390000-07:00', 'value': 200}
		{'timestamp': u'2019-10-27T16:02:45.4500000-07:00', 'value': 300}
Update 3
	CS-Surface61.Testing.ITKORQ
		{'timestamp': u'2019-10-27T16:02:47.4560000-07:00', 'value': 401}
	CS-Surface61.Testing.XBCFZF
		{'timestamp': u'2019-10-27T16:02:47.4560000-07:00', 'value': 400}Note thatCS-Surface61.Testing.ADRLCOdidn't show up past the first loop iteration. That's because the tag had no new data (and theNoneis the result of that - the other two's last update would have shown the same had it been let go one more iteration.)Advanced usageGiven this is Python, we can do a number of handy things.For example, we can predefine values for configurations and use**kwargexpansion to map the configuration dict values to the function.# Send a random number to a tag every 5 seconds foreverfrombirdsongimportCanarySender,Tvq,AnnotationimportrandomimportarrowsenderConfig={'username':'AzureDiamond','password':'hunter2','autoCreateDatasets':True,'autoWriteNoData':False}tagPath='CS-Surface61.Testing.Random Noise'withCanarySender(**senderConfig)assend:rightNow=arrow.utcnow().isoformat()updateData={'tvqs':{tagPath:(rightNow,random.random())},'annotations':{tagPath:(senderConfig['username'],rightNow,'Inserted Data')}}send.storeData(**updateData)sleep(5)(Don't use annotations like this, though...)You can also use the interfaces outside of a context manager. When the object goes out of scope it'll get the connection tokens cleaned up automatically. Note that this willnotguarantee cleanup in the event the program shuts down gracelessly (but it'll try, given the chance).For example, you may want to initiate your live data connection, but not close it. Or you just don't want to indent everything. And, just to be sure, you can close the connection yourself using__exit__. Or to be brutal, go ahead and use thedelcommand.frombirdsongimportCanaryViewimportarrow# Create the object.view=CanaryView()tagSetOne=['CS-Surface61.Testing.Test Tag 1','CS-Surface61.Testing.Test Tag 2']tagSetTwo=['CS-Surface61.Testing.Some Other Tag']loopCount=0whileTrue:print('Update%d'%loopCount)fortagPath,valuesinview.getLiveData(tagSetOne):print('\t%s'%tagPath)forvalueinvalues:print('\t\t%r'%value)fortagPath,valuesinview.getLiveData(tagSetTwo):print('\t%s'%tagPath)forvalueinvalues:print('\t\t%r'%value)sleep(stepTime)# Manually close out the connectionview.__exit__()ContributingFeel free to send suggestions and bug notices (especially if the API shifts/upgrades and is not caught quickly). Features requests are also welcome, though this is primarily meant to act as an interface wrapper library rather than an extension (though 'unpythonic' constructs will be considered bugs :)LicenseApache 2.0